diff --git a/steam_backlog_enforcer/tests/test_actions.py b/steam_backlog_enforcer/tests/test_actions.py
index 641fcc1..96bd197 100644
--- a/steam_backlog_enforcer/tests/test_actions.py
+++ b/steam_backlog_enforcer/tests/test_actions.py
@@ -3,25 +3,16 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
-from unittest.mock import patch
import pytest
from steam_backlog_enforcer import _actions, _allowed_games
from steam_backlog_enforcer._actions import (
- abandon_manual_pick,
- active_manual_picks,
- allowed_app_ids,
- allowed_games,
apply_manual_pick,
can_abandon_manual_pick,
- find_manual_pick,
is_manual_pick_locked,
manual_pick_grace_remaining,
- manual_pick_slots_left,
- status_payload,
)
-from steam_backlog_enforcer._total_block import TotalBlockStatus
from steam_backlog_enforcer.config import State
@@ -29,11 +20,23 @@ def _iso_days_ago(days: float) -> str:
return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
+def _pick(app_id: int = 440, name: str = "TF2", days_ago: float = 1.0) -> dict:
+ return {
+ "app_id": app_id,
+ "game_name": name,
+ "started_at": _iso_days_ago(days_ago),
+ }
+
+
class TestIsManualPickLocked:
+ """Tests for Is Manual Pick Locked."""
+
def test_no_pick_is_unlocked(self) -> None:
+ """Test no pick is unlocked."""
assert is_manual_pick_locked(State()) is False
def test_finished_game_releases_lock(self) -> None:
+ """Test finished game releases lock."""
state = State(
manual_picks=[{"app_id": 5, "game_name": "G", "started_at": ""}],
finished_app_ids=[5],
@@ -42,10 +45,12 @@ class TestIsManualPickLocked:
def test_empty_timestamp_stays_locked(self) -> None:
# Pick set but no started_at → locked (no expiry to evaluate).
+ """Test empty timestamp stays locked."""
state = State(manual_picks=[{"app_id": 5, "game_name": "G", "started_at": ""}])
assert is_manual_pick_locked(state) is True
def test_recent_pick_is_locked(self) -> None:
+ """Test recent pick is locked."""
state = State(
manual_picks=[
{"app_id": 5, "game_name": "G", "started_at": _iso_days_ago(1)}
@@ -54,6 +59,7 @@ class TestIsManualPickLocked:
assert is_manual_pick_locked(state) is True
def test_expired_pick_releases_lock(self) -> None:
+ """Test expired pick releases lock."""
state = State(
manual_picks=[
{
@@ -67,6 +73,7 @@ class TestIsManualPickLocked:
def test_one_active_pick_holds_the_lock_for_all(self) -> None:
# Multi-pick: the lock only lifts when every pick is done/expired.
+ """Test one active pick holds the lock for all."""
state = State(
manual_picks=[
{"app_id": 5, "game_name": "Done", "started_at": _iso_days_ago(1)},
@@ -81,6 +88,7 @@ class TestLegacyManualPickMigration:
"""A lock written by the old single-slot code must survive the upgrade."""
def test_legacy_pick_is_migrated_on_load(self) -> None:
+ """Test legacy pick is migrated on load."""
started = _iso_days_ago(1)
State(
manual_pick_app_id=5,
@@ -98,16 +106,21 @@ class TestLegacyManualPickMigration:
assert is_manual_pick_locked(loaded) is True
def test_new_format_is_left_alone(self) -> None:
+ """Test new format is left alone."""
State(manual_picks=[{"app_id": 9, "game_name": "New", "started_at": ""}]).save()
assert [p["app_id"] for p in State.load().manual_picks] == [9]
def test_no_pick_needs_no_migration(self) -> None:
+ """Test no pick needs no migration."""
State().save()
assert State.load().manual_picks == []
class TestApplyManualPick:
+ """Tests for Apply Manual Pick."""
+
def test_sets_all_fields_and_enforcement_start(self) -> None:
+ """Test sets all fields and enforcement start."""
state = State()
assert apply_manual_pick(state, 440, "Team Fortress 2") is None
assert [p["app_id"] for p in state.manual_picks] == [440]
@@ -119,43 +132,44 @@ class TestApplyManualPick:
assert state.enforcement_started_at != ""
def test_preserves_existing_enforcement_start(self) -> None:
+ """Test preserves existing enforcement start."""
state = State(enforcement_started_at="2020-01-01T00:00:00+00:00")
apply_manual_pick(state, 620, "Portal 2")
assert state.enforcement_started_at == "2020-01-01T00:00:00+00:00"
def test_persists_to_disk(self) -> None:
+ """Test persists to disk."""
state = State()
apply_manual_pick(state, 70, "Half-Life")
assert State.load().current_app_id == 70
def test_default_cap_is_one(self) -> None:
# Callers that do not opt in keep the historical single-slot behaviour.
+ """Test default cap is one."""
state = State()
apply_manual_pick(state, 440, "TF2")
assert apply_manual_pick(state, 620, "Portal 2") is not None
-def _pick(app_id: int = 440, name: str = "TF2", days_ago: float = 1.0) -> dict:
- return {
- "app_id": app_id,
- "game_name": name,
- "started_at": _iso_days_ago(days_ago),
- }
-
-
class TestManualPickGraceRemaining:
+ """Tests for Manual Pick Grace Remaining."""
+
def test_no_pick_returns_none(self) -> None:
+ """Test no pick returns none."""
assert manual_pick_grace_remaining(State(), 440) is None
def test_unknown_app_id_returns_none(self) -> None:
+ """Test unknown app id returns none."""
state = State(manual_picks=[_pick(440)])
assert manual_pick_grace_remaining(state, 999) is None
def test_missing_timestamp_returns_none(self) -> None:
+ """Test missing timestamp returns none."""
state = State(manual_picks=[{"app_id": 440, "game_name": "TF2"}])
assert manual_pick_grace_remaining(state, 440) is None
def test_malformed_timestamp_returns_none(self) -> None:
+ """Test malformed timestamp returns none."""
state = State(
manual_picks=[
{"app_id": 440, "game_name": "TF2", "started_at": "not-a-date"}
@@ -164,12 +178,14 @@ class TestManualPickGraceRemaining:
assert manual_pick_grace_remaining(state, 440) is None
def test_fresh_pick_has_almost_the_full_window(self) -> None:
+ """Test fresh pick has almost the full window."""
state = State(manual_picks=[_pick(days_ago=0)])
remaining = manual_pick_grace_remaining(state, 440)
assert remaining is not None
assert remaining == pytest.approx(_actions.MANUAL_GRACE_DAYS, abs=0.01)
def test_expired_window_is_negative(self) -> None:
+ """Test expired window is negative."""
state = State(manual_picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS + 1)])
remaining = manual_pick_grace_remaining(state, 440)
assert remaining is not None
@@ -177,221 +193,18 @@ class TestManualPickGraceRemaining:
class TestCanAbandonManualPick:
+ """Tests for Can Abandon Manual Pick."""
+
def test_inside_window(self) -> None:
+ """Test inside window."""
state = State(manual_picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS - 1)])
assert can_abandon_manual_pick(state, 440) is True
def test_outside_window(self) -> None:
+ """Test outside window."""
state = State(manual_picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS + 1)])
assert can_abandon_manual_pick(state, 440) is False
def test_no_pick(self) -> None:
+ """Test no pick."""
assert can_abandon_manual_pick(State(), 440) is False
-
-
-class TestAbandonManualPick:
- def _state(self, *, picks: list[dict] | None = None) -> State:
- picks = picks if picks is not None else [_pick()]
- return State(
- manual_picks=picks,
- current_app_id=picks[-1]["app_id"],
- current_game_name=picks[-1]["game_name"],
- )
-
- def test_clears_lock_and_assignment(self) -> None:
- state = self._state()
- assert abandon_manual_pick(state, 440) is True
- assert state.manual_picks == []
- assert state.current_app_id is None
- assert state.current_game_name == ""
- assert is_manual_pick_locked(state) is False
-
- def test_records_cooldown(self) -> None:
- state = self._state()
- abandon_manual_pick(state, 440)
- expiry = datetime.fromisoformat(state.skipped_until["440"])
- expected = datetime.now(timezone.utc) + timedelta(
- days=_actions.ABANDON_COOLDOWN_DAYS
- )
- assert abs((expiry - expected).total_seconds()) < 60
-
- def test_persists_to_disk(self) -> None:
- abandon_manual_pick(self._state(), 440)
- assert State.load().manual_picks == []
-
- def test_refuses_after_grace_and_leaves_state_untouched(self) -> None:
- state = self._state(
- picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS + 1)],
- )
- assert abandon_manual_pick(state, 440) is False
- assert [p["app_id"] for p in state.manual_picks] == [440]
- assert state.current_app_id == 440
- assert state.skipped_until == {}
-
- def test_other_pick_survives_and_takes_the_assignment(self) -> None:
- # The whole point of multi-pick: abandoning one keeps the other locked.
- state = self._state(picks=[_pick(440, "TF2"), _pick(620, "Portal 2")])
- assert abandon_manual_pick(state, 620) is True
- assert [p["app_id"] for p in state.manual_picks] == [440]
- assert state.current_app_id == 440
- assert state.current_game_name == "TF2"
- assert is_manual_pick_locked(state) is True
-
- def test_keeps_unrelated_assignment(self) -> None:
- # A pick that is not the current assignment must not clear it.
- state = self._state(picks=[_pick(440, "TF2")])
- state.current_app_id = 70
- state.current_game_name = "Half-Life"
- abandon_manual_pick(state, 440)
- assert state.current_app_id == 70
- assert state.current_game_name == "Half-Life"
-
-
-class TestActiveManualPicksAndAllowedSet:
- def test_finished_pick_drops_out(self) -> None:
- state = State(manual_picks=[_pick(440)], finished_app_ids=[440])
- assert active_manual_picks(state) == []
- assert is_manual_pick_locked(state) is False
-
- def test_expired_pick_drops_out(self) -> None:
- state = State(
- manual_picks=[_pick(days_ago=_allowed_games.MANUAL_LOCK_DAYS + 1)],
- )
- assert active_manual_picks(state) == []
-
- def test_missing_timestamp_stays_active(self) -> None:
- # No deadline to evaluate → stay locked (safe answer for an enforcer).
- state = State(manual_picks=[{"app_id": 440, "game_name": "TF2"}])
- assert len(active_manual_picks(state)) == 1
-
- def test_malformed_timestamp_stays_active(self) -> None:
- state = State(
- manual_picks=[
- {"app_id": 440, "game_name": "TF2", "started_at": "not-a-date"}
- ]
- )
- assert len(active_manual_picks(state)) == 1
-
- def test_entry_without_app_id_is_ignored(self) -> None:
- state = State(manual_picks=[{"game_name": "Corrupt"}])
- assert active_manual_picks(state) == []
-
- def test_allowed_set_unions_picks_and_assignment(self) -> None:
- state = State(
- manual_picks=[_pick(440, "TF2"), _pick(620, "Portal 2")],
- current_app_id=70,
- current_game_name="Half-Life",
- )
- assert allowed_app_ids(state) == {70, 440, 620}
- assert allowed_games(state)[0] == (70, "Half-Life")
-
- def test_allowed_set_deduplicates_assignment(self) -> None:
- state = State(
- manual_picks=[_pick(440, "TF2")],
- current_app_id=440,
- current_game_name="TF2",
- )
- assert allowed_app_ids(state) == {440}
- assert allowed_games(state) == [(440, "TF2")]
-
- def test_empty_state_allows_nothing(self) -> None:
- assert allowed_app_ids(State()) == set()
- assert allowed_games(State()) == []
-
- def test_find_manual_pick(self) -> None:
- state = State(manual_picks=[_pick(440, "TF2")])
- found = find_manual_pick(state, 440)
- assert found is not None
- assert found["game_name"] == "TF2"
- assert find_manual_pick(state, 999) is None
-
-
-class TestApplyManualPickCap:
- def test_appends_second_pick(self) -> None:
- state = State(manual_picks=[_pick(440, "TF2")])
- assert apply_manual_pick(state, 620, "Portal 2", max_picks=2) is None
- assert [p["app_id"] for p in state.manual_picks] == [440, 620]
- # Newest pick becomes the assignment.
- assert state.current_app_id == 620
-
- def test_refuses_beyond_cap(self) -> None:
- state = State(manual_picks=[_pick(440, "TF2"), _pick(620, "Portal 2")])
- refused = apply_manual_pick(state, 70, "Half-Life", max_picks=2)
- assert refused is not None
- assert "cap is 2" in refused
- assert len(state.manual_picks) == 2
-
- def test_refuses_duplicate_pick(self) -> None:
- state = State(manual_picks=[_pick(440, "TF2")])
- refused = apply_manual_pick(state, 440, "TF2", max_picks=2)
- assert refused is not None
- assert "already one of your manual picks" in refused
-
- def test_prunes_finished_entries(self) -> None:
- state = State(manual_picks=[_pick(440, "TF2")], finished_app_ids=[440])
- assert apply_manual_pick(state, 620, "Portal 2", max_picks=2) is None
- assert [p["app_id"] for p in state.manual_picks] == [620]
-
- def test_slots_left(self) -> None:
- state = State(manual_picks=[_pick(440, "TF2")])
- assert manual_pick_slots_left(state, 2) == 1
- assert manual_pick_slots_left(state, 1) == 0
-
-
-class TestStatusPayload:
- def _patch_leaves(
- self,
- *,
- total_block: TotalBlockStatus,
- installed: list[tuple[int, str]],
- store_blocked: bool,
- protected_ids: set[int],
- ) -> object:
- return patch.multiple(
- "steam_backlog_enforcer._actions",
- get_total_block_status=lambda: total_block,
- get_installed_games=lambda: installed,
- is_store_blocked=lambda: store_blocked,
- is_protected_app=lambda aid: aid in protected_ids,
- )
-
- def test_assigned_and_installed(self) -> None:
- block = TotalBlockStatus(
- active=False, started_at=None, until=None, days=0, days_remaining=0.0
- )
- with self._patch_leaves(
- total_block=block,
- installed=[(440, "TF2"), (70, "HL")],
- store_blocked=True,
- protected_ids=set(),
- ):
- payload = status_payload(State(current_app_id=440, current_game_name="TF2"))
- assert payload["current_app_id"] == 440
- assert payload["installed_count"] == 2
- assert payload["assigned_game_installed"] is True
- assert payload["store_blocked"] is True
- assert payload["manual_pick_locked"] is False
-
- def test_no_assignment_and_protected_filtering(self) -> None:
- block = TotalBlockStatus(
- active=True,
- started_at=None,
- until=datetime(2030, 1, 1, tzinfo=timezone.utc),
- days=3,
- days_remaining=2.55,
- )
- with self._patch_leaves(
- total_block=block,
- installed=[(440, "TF2"), (228980, "Steamworks")],
- store_blocked=False,
- protected_ids={228980},
- ):
- payload = status_payload(State())
- assert payload["current_app_id"] is None
- assert payload["current_game_name"] is None
- assert payload["assigned_game_installed"] is None
- # protected app filtered out of the count.
- assert payload["installed_count"] == 1
- assert payload["total_block"]["active"] is True
- assert payload["total_block"]["days_remaining"] == 2.5
- assert payload["total_block"]["until"] == "2030-01-01T00:00:00+00:00"
diff --git a/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py b/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py
new file mode 100644
index 0000000..e9c04c4
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py
@@ -0,0 +1,206 @@
+"""Tests for the stdout-free, state-only cores in ``_actions``."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+from steam_backlog_enforcer import _actions, _allowed_games
+from steam_backlog_enforcer._actions import (
+ abandon_manual_pick,
+ active_manual_picks,
+ allowed_app_ids,
+ allowed_games,
+ apply_manual_pick,
+ find_manual_pick,
+ is_manual_pick_locked,
+ manual_pick_slots_left,
+)
+from steam_backlog_enforcer.config import State
+
+
+def _iso_days_ago(days: float) -> str:
+ return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
+
+
+def _pick(app_id: int = 440, name: str = "TF2", days_ago: float = 1.0) -> dict:
+ return {
+ "app_id": app_id,
+ "game_name": name,
+ "started_at": _iso_days_ago(days_ago),
+ }
+
+
+class TestAbandonManualPick:
+ """Tests for Abandon Manual Pick."""
+
+ def _state(self, *, picks: list[dict] | None = None) -> State:
+ """Test state."""
+ picks = picks if picks is not None else [_pick()]
+ return State(
+ manual_picks=picks,
+ current_app_id=picks[-1]["app_id"],
+ current_game_name=picks[-1]["game_name"],
+ )
+
+ def test_clears_lock_and_assignment(self) -> None:
+ """Test clears lock and assignment."""
+ state = self._state()
+ assert abandon_manual_pick(state, 440) is True
+ assert state.manual_picks == []
+ assert state.current_app_id is None
+ assert state.current_game_name == ""
+ assert is_manual_pick_locked(state) is False
+
+ def test_records_cooldown(self) -> None:
+ """Test records cooldown."""
+ state = self._state()
+ abandon_manual_pick(state, 440)
+ expiry = datetime.fromisoformat(state.skipped_until["440"])
+ expected = datetime.now(timezone.utc) + timedelta(
+ days=_actions.ABANDON_COOLDOWN_DAYS
+ )
+ assert abs((expiry - expected).total_seconds()) < 60
+
+ def test_persists_to_disk(self) -> None:
+ """Test persists to disk."""
+ abandon_manual_pick(self._state(), 440)
+ assert State.load().manual_picks == []
+
+ def test_refuses_after_grace_and_leaves_state_untouched(self) -> None:
+ """Test refuses after grace and leaves state untouched."""
+ state = self._state(
+ picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS + 1)],
+ )
+ assert abandon_manual_pick(state, 440) is False
+ assert [p["app_id"] for p in state.manual_picks] == [440]
+ assert state.current_app_id == 440
+ assert state.skipped_until == {}
+
+ def test_other_pick_survives_and_takes_the_assignment(self) -> None:
+ # The whole point of multi-pick: abandoning one keeps the other locked.
+ """Test other pick survives and takes the assignment."""
+ state = self._state(picks=[_pick(440, "TF2"), _pick(620, "Portal 2")])
+ assert abandon_manual_pick(state, 620) is True
+ assert [p["app_id"] for p in state.manual_picks] == [440]
+ assert state.current_app_id == 440
+ assert state.current_game_name == "TF2"
+ assert is_manual_pick_locked(state) is True
+
+ def test_keeps_unrelated_assignment(self) -> None:
+ # A pick that is not the current assignment must not clear it.
+ """Test keeps unrelated assignment."""
+ state = self._state(picks=[_pick(440, "TF2")])
+ state.current_app_id = 70
+ state.current_game_name = "Half-Life"
+ abandon_manual_pick(state, 440)
+ assert state.current_app_id == 70
+ assert state.current_game_name == "Half-Life"
+
+
+class TestActiveManualPicksAndAllowedSet:
+ """Tests for Active Manual Picks And Allowed Set."""
+
+ def test_finished_pick_drops_out(self) -> None:
+ """Test finished pick drops out."""
+ state = State(manual_picks=[_pick(440)], finished_app_ids=[440])
+ assert active_manual_picks(state) == []
+ assert is_manual_pick_locked(state) is False
+
+ def test_expired_pick_drops_out(self) -> None:
+ """Test expired pick drops out."""
+ state = State(
+ manual_picks=[_pick(days_ago=_allowed_games.MANUAL_LOCK_DAYS + 1)],
+ )
+ assert active_manual_picks(state) == []
+
+ def test_missing_timestamp_stays_active(self) -> None:
+ # No deadline to evaluate → stay locked (safe answer for an enforcer).
+ """Test missing timestamp stays active."""
+ state = State(manual_picks=[{"app_id": 440, "game_name": "TF2"}])
+ assert len(active_manual_picks(state)) == 1
+
+ def test_malformed_timestamp_stays_active(self) -> None:
+ """Test malformed timestamp stays active."""
+ state = State(
+ manual_picks=[
+ {"app_id": 440, "game_name": "TF2", "started_at": "not-a-date"}
+ ]
+ )
+ assert len(active_manual_picks(state)) == 1
+
+ def test_entry_without_app_id_is_ignored(self) -> None:
+ """Test entry without app id is ignored."""
+ state = State(manual_picks=[{"game_name": "Corrupt"}])
+ assert active_manual_picks(state) == []
+
+ def test_allowed_set_unions_picks_and_assignment(self) -> None:
+ """Test allowed set unions picks and assignment."""
+ state = State(
+ manual_picks=[_pick(440, "TF2"), _pick(620, "Portal 2")],
+ current_app_id=70,
+ current_game_name="Half-Life",
+ )
+ assert allowed_app_ids(state) == {70, 440, 620}
+ assert allowed_games(state)[0] == (70, "Half-Life")
+
+ def test_allowed_set_deduplicates_assignment(self) -> None:
+ """Test allowed set deduplicates assignment."""
+ state = State(
+ manual_picks=[_pick(440, "TF2")],
+ current_app_id=440,
+ current_game_name="TF2",
+ )
+ assert allowed_app_ids(state) == {440}
+ assert allowed_games(state) == [(440, "TF2")]
+
+ def test_empty_state_allows_nothing(self) -> None:
+ """Test empty state allows nothing."""
+ assert allowed_app_ids(State()) == set()
+ assert allowed_games(State()) == []
+
+ def test_find_manual_pick(self) -> None:
+ """Test find manual pick."""
+ state = State(manual_picks=[_pick(440, "TF2")])
+ found = find_manual_pick(state, 440)
+ assert found is not None
+ assert found["game_name"] == "TF2"
+ assert find_manual_pick(state, 999) is None
+
+
+class TestApplyManualPickCap:
+ """Tests for Apply Manual Pick Cap."""
+
+ def test_appends_second_pick(self) -> None:
+ """Test appends second pick."""
+ state = State(manual_picks=[_pick(440, "TF2")])
+ assert apply_manual_pick(state, 620, "Portal 2", max_picks=2) is None
+ assert [p["app_id"] for p in state.manual_picks] == [440, 620]
+ # Newest pick becomes the assignment.
+ assert state.current_app_id == 620
+
+ def test_refuses_beyond_cap(self) -> None:
+ """Test refuses beyond cap."""
+ state = State(manual_picks=[_pick(440, "TF2"), _pick(620, "Portal 2")])
+ refused = apply_manual_pick(state, 70, "Half-Life", max_picks=2)
+ assert refused is not None
+ assert "cap is 2" in refused
+ assert len(state.manual_picks) == 2
+
+ def test_refuses_duplicate_pick(self) -> None:
+ """Test refuses duplicate pick."""
+ state = State(manual_picks=[_pick(440, "TF2")])
+ refused = apply_manual_pick(state, 440, "TF2", max_picks=2)
+ assert refused is not None
+ assert "already one of your manual picks" in refused
+
+ def test_prunes_finished_entries(self) -> None:
+ """Test prunes finished entries."""
+ state = State(manual_picks=[_pick(440, "TF2")], finished_app_ids=[440])
+ assert apply_manual_pick(state, 620, "Portal 2", max_picks=2) is None
+ assert [p["app_id"] for p in state.manual_picks] == [620]
+
+ def test_slots_left(self) -> None:
+ """Test slots left."""
+ state = State(manual_picks=[_pick(440, "TF2")])
+ assert manual_pick_slots_left(state, 2) == 1
+ assert manual_pick_slots_left(state, 1) == 0
diff --git a/steam_backlog_enforcer/tests/test_actions_status_payload.py b/steam_backlog_enforcer/tests/test_actions_status_payload.py
new file mode 100644
index 0000000..a0db66f
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_actions_status_payload.py
@@ -0,0 +1,88 @@
+"""Tests for the stdout-free, state-only cores in ``_actions``."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+from steam_backlog_enforcer._actions import (
+ status_payload,
+)
+from steam_backlog_enforcer._total_block import TotalBlockStatus
+from steam_backlog_enforcer.config import State
+
+
+def _iso_days_ago(days: float) -> str:
+ return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
+
+
+def _pick(app_id: int = 440, name: str = "TF2", days_ago: float = 1.0) -> dict:
+ return {
+ "app_id": app_id,
+ "game_name": name,
+ "started_at": _iso_days_ago(days_ago),
+ }
+
+
+class TestStatusPayload:
+ """Tests for Status Payload."""
+
+ def _patch_leaves(
+ self,
+ *,
+ total_block: TotalBlockStatus,
+ installed: list[tuple[int, str]],
+ store_blocked: bool,
+ protected_ids: set[int],
+ ) -> object:
+ """Test patch leaves."""
+ return patch.multiple(
+ "steam_backlog_enforcer._actions",
+ get_total_block_status=lambda: total_block,
+ get_installed_games=lambda: installed,
+ is_store_blocked=lambda: store_blocked,
+ is_protected_app=lambda aid: aid in protected_ids,
+ )
+
+ def test_assigned_and_installed(self) -> None:
+ """Test assigned and installed."""
+ block = TotalBlockStatus(
+ active=False, started_at=None, until=None, days=0, days_remaining=0.0
+ )
+ with self._patch_leaves(
+ total_block=block,
+ installed=[(440, "TF2"), (70, "HL")],
+ store_blocked=True,
+ protected_ids=set(),
+ ):
+ payload = status_payload(State(current_app_id=440, current_game_name="TF2"))
+ assert payload["current_app_id"] == 440
+ assert payload["installed_count"] == 2
+ assert payload["assigned_game_installed"] is True
+ assert payload["store_blocked"] is True
+ assert payload["manual_pick_locked"] is False
+
+ def test_no_assignment_and_protected_filtering(self) -> None:
+ """Test no assignment and protected filtering."""
+ block = TotalBlockStatus(
+ active=True,
+ started_at=None,
+ until=datetime(2030, 1, 1, tzinfo=timezone.utc),
+ days=3,
+ days_remaining=2.55,
+ )
+ with self._patch_leaves(
+ total_block=block,
+ installed=[(440, "TF2"), (228980, "Steamworks")],
+ store_blocked=False,
+ protected_ids={228980},
+ ):
+ payload = status_payload(State())
+ assert payload["current_app_id"] is None
+ assert payload["current_game_name"] is None
+ assert payload["assigned_game_installed"] is None
+ # protected app filtered out of the count.
+ assert payload["installed_count"] == 1
+ assert payload["total_block"]["active"] is True
+ assert payload["total_block"]["days_remaining"] == 2.5
+ assert payload["total_block"]["until"] == "2030-01-01T00:00:00+00:00"
diff --git a/steam_backlog_enforcer/tests/test_config.py b/steam_backlog_enforcer/tests/test_config.py
index f04f953..138fe3b 100644
--- a/steam_backlog_enforcer/tests/test_config.py
+++ b/steam_backlog_enforcer/tests/test_config.py
@@ -12,7 +12,6 @@ from steam_backlog_enforcer.config import (
Config,
State,
_atomic_write,
- interactive_setup,
load_snapshot,
save_snapshot,
)
@@ -25,16 +24,19 @@ class TestAtomicWrite:
"""Tests for _atomic_write."""
def test_writes_file(self, tmp_path: Path) -> None:
+ """Test writes file."""
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:
+ """Test creates parent dirs."""
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:
+ """Test cleanup on write error."""
target = tmp_path / "out.json"
with (
patch(
@@ -49,6 +51,7 @@ class TestAtomicWrite:
assert tmp_files == []
def test_cleanup_on_replace_error(self, tmp_path: Path) -> None:
+ """Test cleanup on replace error."""
target = tmp_path / "out.json"
with (
patch.object(
@@ -68,6 +71,7 @@ class TestConfig:
"""Tests for Config dataclass."""
def test_defaults(self) -> None:
+ """Test defaults."""
cfg = Config()
assert cfg.steam_api_key == ""
assert cfg.steam_id == ""
@@ -77,6 +81,7 @@ class TestConfig:
assert cfg.desktop_notifications is True
def test_save(self, tmp_path: Path) -> None:
+ """Test save."""
cfg = Config(steam_api_key="abc", steam_id="123")
config_dir = tmp_path / "cfg"
config_file = config_dir / "config.json"
@@ -90,6 +95,7 @@ class TestConfig:
assert data["steam_id"] == "123"
def test_load_existing(self, tmp_path: Path) -> None:
+ """Test load existing."""
config_file = tmp_path / "config.json"
config_file.write_text(
json.dumps({"steam_api_key": "key1", "steam_id": "id1"}) + "\n",
@@ -101,12 +107,14 @@ class TestConfig:
assert cfg.steam_id == "id1"
def test_load_missing(self, tmp_path: Path) -> None:
+ """Test load missing."""
config_file = tmp_path / "nonexistent.json"
with patch("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:
+ """Test load extra fields ignored."""
config_file = tmp_path / "config.json"
config_file.write_text(
json.dumps({"steam_api_key": "k", "unknown_field": 42}) + "\n",
@@ -121,12 +129,14 @@ class TestState:
"""Tests for State dataclass."""
def test_defaults(self) -> None:
+ """Test defaults."""
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:
+ """Test save."""
state = State(current_app_id=100, current_game_name="TestGame")
config_dir = tmp_path / "cfg"
state_file = config_dir / "state.json"
@@ -140,6 +150,7 @@ class TestState:
assert data["current_game_name"] == "TestGame"
def test_load_existing(self, tmp_path: Path) -> None:
+ """Test load existing."""
state_file = tmp_path / "state.json"
state_file.write_text(
json.dumps(
@@ -158,12 +169,14 @@ class TestState:
assert st.finished_app_ids == [1, 2]
def test_load_missing(self, tmp_path: Path) -> None:
+ """Test load missing."""
state_file = tmp_path / "nonexistent.json"
with patch("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:
+ """Test load corrupt."""
state_file = tmp_path / "state.json"
state_file.write_text("not valid json{{", encoding="utf-8")
with patch("steam_backlog_enforcer.config.STATE_FILE", state_file):
@@ -172,6 +185,7 @@ class TestState:
assert st.current_game_name == ""
def test_skip_for_days_records_iso_timestamp(self) -> None:
+ """Test skip for days records iso timestamp."""
state = State()
state.skip_for_days(42, 7)
assert "42" in state.skipped_until
@@ -183,6 +197,7 @@ class TestState:
assert 6 * 86400 < delta <= 7 * 86400 + 1
def test_active_skipped_ids_returns_active(self) -> None:
+ """Test active skipped ids returns active."""
from datetime import datetime, timedelta, timezone
state = State()
@@ -193,6 +208,7 @@ class TestState:
assert "100" in state.skipped_until
def test_active_skipped_ids_prunes_expired(self) -> None:
+ """Test active skipped ids prunes expired."""
from datetime import datetime, timedelta, timezone
state = State()
@@ -202,6 +218,7 @@ class TestState:
assert "50" not in state.skipped_until
def test_active_skipped_ids_prunes_malformed(self) -> None:
+ """Test active skipped ids prunes malformed."""
state = State()
state.skipped_until["77"] = "not-a-date"
assert state.active_skipped_ids() == set()
@@ -212,6 +229,7 @@ class TestSnapshot:
"""Tests for snapshot save/load."""
def test_save_and_load(self, tmp_path: Path) -> None:
+ """Test save and load."""
config_dir = tmp_path / "cfg"
snap_file = config_dir / "snapshot.json"
with (
@@ -224,41 +242,7 @@ class TestSnapshot:
assert loaded == data
def test_load_none(self, tmp_path: Path) -> None:
+ """Test load none."""
snap_file = tmp_path / "nonexistent.json"
with patch("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("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
- patch("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("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
- patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file),
- patch("builtins.input", side_effect=["key", ""]),
- pytest.raises(SystemExit),
- ):
- interactive_setup()
diff --git a/steam_backlog_enforcer/tests/test_config_interactive_setup.py b/steam_backlog_enforcer/tests/test_config_interactive_setup.py
new file mode 100644
index 0000000..e8b1afb
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_config_interactive_setup.py
@@ -0,0 +1,53 @@
+"""Tests for config module."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from unittest.mock import patch
+
+import pytest
+
+from steam_backlog_enforcer.config import (
+ interactive_setup,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+class TestInteractiveSetup:
+ """Tests for interactive_setup."""
+
+ def test_success(self, tmp_path: Path) -> None:
+ """Test success."""
+ config_dir = tmp_path / "cfg"
+ config_file = config_dir / "config.json"
+ with (
+ patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
+ patch("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:
+ """Test empty api key exits."""
+ with (
+ patch("builtins.input", return_value=""),
+ pytest.raises(SystemExit),
+ ):
+ interactive_setup()
+
+ def test_empty_steam_id_exits(self, tmp_path: Path) -> None:
+ """Test empty steam id exits."""
+ config_dir = tmp_path / "cfg"
+ config_file = config_dir / "config.json"
+ with (
+ patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
+ patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file),
+ patch("builtins.input", side_effect=["key", ""]),
+ pytest.raises(SystemExit),
+ ):
+ interactive_setup()
diff --git a/steam_backlog_enforcer/tests/test_hltb.py b/steam_backlog_enforcer/tests/test_hltb.py
index cb33e75..363f8c7 100644
--- a/steam_backlog_enforcer/tests/test_hltb.py
+++ b/steam_backlog_enforcer/tests/test_hltb.py
@@ -4,16 +4,14 @@ from __future__ import annotations
import asyncio
import json
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
from steam_backlog_enforcer._hltb_search import (
_AuthInfo,
- _build_search_payload,
_get_hltb_search_url,
- _pick_best_hltb_entry,
_similarity,
)
from steam_backlog_enforcer.hltb import (
@@ -30,6 +28,7 @@ class TestHltbCache:
"""Tests for HLTB cache I/O."""
def test_load_cache_exists(self, tmp_path: Path) -> None:
+ """Test load cache exists."""
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8")
with patch("steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file):
@@ -37,17 +36,20 @@ class TestHltbCache:
assert result == {440: 10.5}
def test_load_cache_missing(self, tmp_path: Path) -> None:
+ """Test load cache missing."""
cache_file = tmp_path / "nonexistent.json"
with patch("steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file):
assert load_hltb_cache() == {}
def test_load_cache_corrupt(self, tmp_path: Path) -> None:
+ """Test load cache corrupt."""
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text("not json", encoding="utf-8")
with patch("steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file):
assert load_hltb_cache() == {}
def test_save_cache(self, tmp_path: Path) -> None:
+ """Test save cache."""
cache_file = tmp_path / "hltb_cache.json"
with (
patch(
@@ -60,6 +62,7 @@ class TestHltbCache:
assert cache_file.exists()
def test_save_cache_os_error(self, tmp_path: Path) -> None:
+ """Test save cache os error."""
with patch(
"steam_backlog_enforcer._hltb_types._atomic_write",
side_effect=OSError("disk full"),
@@ -67,6 +70,7 @@ class TestHltbCache:
save_hltb_cache({440: 10.5}) # Should not raise
def test_save_cache_full_extras_skips_existing_read(self, tmp_path: Path) -> None:
+ """Test save cache full extras skips existing read."""
from steam_backlog_enforcer._hltb_types import _HLTBExtras
cache_file = tmp_path / "hltb_cache.json"
@@ -89,6 +93,7 @@ class TestGetHltbSearchUrl:
"""Tests for _get_hltb_search_url."""
def test_discovers_url(self) -> None:
+ """Test discovers url."""
mock_info = MagicMock()
mock_info.search_url = "/api/search/abc"
with patch("steam_backlog_enforcer._hltb_search.HTMLRequests") as mock_html:
@@ -98,12 +103,14 @@ class TestGetHltbSearchUrl:
assert url == "https://howlongtobeat.com/api/search/abc"
def test_fallback_url(self) -> None:
+ """Test fallback url."""
with patch("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:
+ """Test first returns none second returns info."""
mock_info = MagicMock()
mock_info.search_url = "/api/search/xyz"
with patch("steam_backlog_enforcer._hltb_search.HTMLRequests") as mock_html:
@@ -113,6 +120,7 @@ class TestGetHltbSearchUrl:
assert url == "https://howlongtobeat.com/api/search/xyz"
def test_exception_fallback(self) -> None:
+ """Test exception fallback."""
with patch("steam_backlog_enforcer._hltb_search.HTMLRequests") as mock_html:
mock_html.send_website_request_getcode.side_effect = RuntimeError
url = _get_hltb_search_url()
@@ -123,6 +131,7 @@ class TestGetAuthInfo:
"""Tests for _get_auth_info."""
def test_success(self) -> None:
+ """Test success."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
@@ -140,6 +149,7 @@ class TestGetAuthInfo:
assert result == _AuthInfo("abc123", "ign_x", "ff")
def test_success_no_hp(self) -> None:
+ """Test success no hp."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"token": "abc123"})
@@ -155,6 +165,7 @@ class TestGetAuthInfo:
assert result == _AuthInfo("abc123")
def test_no_token_key(self) -> None:
+ """Test no token key."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"notoken": True})
@@ -170,6 +181,7 @@ class TestGetAuthInfo:
assert result is None
def test_non_200(self) -> None:
+ """Test non 200."""
mock_resp = AsyncMock()
mock_resp.status = 500
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
@@ -184,6 +196,7 @@ class TestGetAuthInfo:
assert result is None
def test_client_error(self) -> None:
+ """Test client error."""
mock_session = MagicMock()
ctx = AsyncMock()
ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError)
@@ -200,248 +213,13 @@ class TestSimilarity:
"""Tests for _similarity."""
def test_identical(self) -> None:
+ """Test identical."""
assert _similarity("hello", "hello") == 1.0
def test_different(self) -> None:
+ """Test different."""
assert _similarity("abc", "xyz") < 0.5
def test_case_insensitive(self) -> None:
+ """Test case insensitive."""
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"
diff --git a/steam_backlog_enforcer/tests/test_hltb_build_search_payload.py b/steam_backlog_enforcer/tests/test_hltb_build_search_payload.py
new file mode 100644
index 0000000..8402d21
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_build_search_payload.py
@@ -0,0 +1,35 @@
+"""Tests for hltb module."""
+
+from __future__ import annotations
+
+import json
+
+from steam_backlog_enforcer._hltb_search import (
+ _AuthInfo,
+ _build_search_payload,
+)
+
+
+class TestBuildSearchPayload:
+ """Tests for _build_search_payload."""
+
+ def test_returns_json(self) -> None:
+ """Test returns json."""
+ 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:
+ """Test with auth."""
+ 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:
+ """Test with auth no hp key."""
+ auth = _AuthInfo("t")
+ payload = _build_search_payload("TF2", auth=auth)
+ data = json.loads(payload)
+ assert "" not in data
diff --git a/steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py b/steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py
new file mode 100644
index 0000000..e004e83
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py
@@ -0,0 +1,128 @@
+"""Tests for hltb module — part 2 (missing coverage)."""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import patch
+
+from typing_extensions import Self
+
+from steam_backlog_enforcer._hltb_search import _AuthInfo
+from steam_backlog_enforcer.hltb import (
+ _fetch_batch_confidence_only,
+ fetch_hltb_confidence,
+ fetch_hltb_confidence_cached,
+)
+
+PKG = "steam_backlog_enforcer.hltb"
+
+
+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:
+ """Test fetch batch confidence only returns empty without auth."""
+ 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:
+ """Test fetch batch confidence only handles empty hp and default counts."""
+ 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_batch_confidence_only_with_hp_key_and_prepopulated_count_comp(
+ self,
+ ) -> None:
+ """Test fetch batch confidence only with hp key and prepopulated count comp."""
+ 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="hpk", hp_val="hpv"),
+ ),
+ 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={1: 42},
+ ),
+ )
+ assert result == []
+ mock_search.assert_called_once()
+
+ def test_fetch_hltb_confidence_initializes_optional_dicts(self) -> None:
+ """Test fetch hltb confidence initializes optional dicts."""
+ 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:
+ """Test fetch hltb confidence empty games returns empty."""
+ 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:
+ """Test fetch hltb confidence cached all cached skips fetch."""
+ 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()
diff --git a/steam_backlog_enforcer/tests/test_hltb_detail.py b/steam_backlog_enforcer/tests/test_hltb_detail.py
index 570c23b..e3f2693 100644
--- a/steam_backlog_enforcer/tests/test_hltb_detail.py
+++ b/steam_backlog_enforcer/tests/test_hltb_detail.py
@@ -3,9 +3,8 @@
from __future__ import annotations
import asyncio
-import json
from typing import Any
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import AsyncMock, patch
import aiohttp
from typing_extensions import Self
@@ -15,34 +14,52 @@ from steam_backlog_enforcer._hltb_detail import (
_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 steam_backlog_enforcer._hltb_types import (
- _SAVE_INTERVAL,
HLTBResult,
- _HLTBExtras,
)
+class _FakeTextResponse:
+ """Async context manager mimicking aiohttp response for text."""
+
+ def __init__(self, status: int, text: str = "") -> None:
+ """Test init."""
+ self.status = status
+ self._text = text
+
+ async def __aenter__(self) -> Self:
+ """Test aenter."""
+ return self
+
+ async def __aexit__(self, *args: object) -> None:
+ """Test aexit."""
+
+ async def text(self) -> str:
+ """Test text."""
+ return self._text
+
+
class TestInternalHelpers:
"""Tests for internal helper coverage."""
def test_as_positive_int_float(self) -> None:
+ """Test as positive int float."""
assert _as_positive_int(1.9) == 1
def test_as_positive_int_invalid_type(self) -> None:
+ """Test as positive int invalid type."""
assert not _as_positive_int(object())
def test_extract_base_leisure_non_dict_game(self) -> None:
+ """Test extract base leisure non dict game."""
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:
+ """Test extract base leisure platform data comp high is max."""
data: dict[str, Any] = {
"game": [{"comp_100_h": 16063}],
"platformData": [{"platform": "PC", "comp_high": 23760}],
@@ -50,6 +67,7 @@ class TestInternalHelpers:
assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2)
def test_extract_base_leisure_h_field_exceeds_platform_comp_high(self) -> None:
+ """Test extract base leisure h field exceeds platform comp high."""
data: dict[str, Any] = {
"game": [{"comp_100_h": 25000}],
"platformData": [{"platform": "PC", "comp_high": 23760}],
@@ -57,6 +75,7 @@ class TestInternalHelpers:
assert _extract_base_leisure_hours(data) == round(25000 / 3600, 2)
def test_extract_base_leisure_max_of_multiple_platforms(self) -> None:
+ """Test extract base leisure max of multiple platforms."""
data: dict[str, Any] = {
"game": [{}],
"platformData": [
@@ -67,6 +86,7 @@ class TestInternalHelpers:
assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2)
def test_extract_base_leisure_platform_data_not_list(self) -> None:
+ """Test extract base leisure platform data not list."""
data: dict[str, Any] = {
"game": [{"comp_100_h": 16063}],
"platformData": "not_a_list",
@@ -74,6 +94,7 @@ class TestInternalHelpers:
assert _extract_base_leisure_hours(data) == round(16063 / 3600, 2)
def test_extract_base_leisure_platform_non_dict_entry_skipped(self) -> None:
+ """Test extract base leisure platform non dict entry skipped."""
data: dict[str, Any] = {
"game": [{"comp_100_h": 16063}],
"platformData": ["bad", {"platform": "PC", "comp_high": 23760}],
@@ -81,6 +102,7 @@ class TestInternalHelpers:
assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2)
def test_extract_base_leisure_platform_comp_high_zero_skipped(self) -> None:
+ """Test extract base leisure platform comp high zero skipped."""
data: dict[str, Any] = {
"game": [{"comp_100_h": 16063}],
"platformData": [{"platform": "PC", "comp_high": 0}],
@@ -88,6 +110,7 @@ class TestInternalHelpers:
assert _extract_base_leisure_hours(data) == round(16063 / 3600, 2)
def test_extract_base_leisure_max_of_h_fields(self) -> None:
+ """Test extract base leisure max of h fields."""
data: dict[str, Any] = {
"game": [
{
@@ -101,12 +124,14 @@ class TestInternalHelpers:
assert _extract_base_leisure_hours(data) == round(17959 / 3600, 2)
def test_extract_base_leisure_fallback_to_avg_comp_main(self) -> None:
+ """Test extract base leisure fallback to avg comp main."""
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:
+ """Test extract dlc relationships skips non dict."""
data: dict[str, Any] = {
"relationships": [
"bad",
@@ -116,6 +141,7 @@ class TestInternalHelpers:
assert _extract_dlc_relationships(data) == [(7, 1.0)]
def test_collect_dlc_relationships_ignores_non_positive_id(self) -> None:
+ """Test collect dlc relationships ignores non positive id."""
valid = [
HLTBResult(
app_id=1,
@@ -137,6 +163,7 @@ class TestInternalHelpers:
assert ids == []
def test_apply_dlc_leisure_overrides(self) -> None:
+ """Test apply dlc leisure overrides."""
adjusted = _apply_dlc_leisure_overrides(
base_hours=6.0,
dlc_rels=[(10, 1.0), (11, 2.0)],
@@ -145,14 +172,20 @@ class TestInternalHelpers:
assert adjusted == 8.0
def test_fetch_dlc_leisure_hours_empty(self) -> None:
+ """Test fetch dlc leisure hours empty."""
+
async def _run() -> dict[int, float]:
+ """Test run."""
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:
+ """Test fetch dlc leisure hours skips none data."""
+
async def _run() -> dict[int, float]:
+ """Test run."""
async with aiohttp.ClientSession() as session:
with patch(
"steam_backlog_enforcer._hltb_detail._fetch_detail_one",
@@ -168,12 +201,14 @@ class TestInternalHelpers:
assert asyncio.run(_run()) == {}
def test_fetch_dlc_leisure_hours_skips_non_positive_leisure(self) -> None:
+ """Test fetch dlc leisure hours skips non positive leisure."""
bad_dlc_data: dict[str, Any] = {
"game": [{"comp_100_h": 0, "comp_100": 0}],
"relationships": [],
}
async def _run() -> dict[int, float]:
+ """Test run."""
async with aiohttp.ClientSession() as session:
with patch(
"steam_backlog_enforcer._hltb_detail._fetch_detail_one",
@@ -187,406 +222,3 @@ class TestInternalHelpers:
)
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 = (
- '"
- )
- 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, "no script")
- 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(
- "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(
- "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(
- "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(
- "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(
- "steam_backlog_enforcer._hltb_detail._fetch_detail_one",
- new_callable=AsyncMock,
- return_value=game_data,
- ),
- patch("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(
- "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(
- "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(
- "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(
- "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
-
- def test_count_comp_from_detail_page_stored_in_extras(self) -> None:
- """Line 254: extras.count_comp updated when game detail has count_comp > 0."""
- 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, "count_comp": 99}],
- "relationships": [],
- }
- cache: dict[int, float] = {}
- extras = _HLTBExtras()
- with patch(
- "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.count_comp[440] == 99
diff --git a/steam_backlog_enforcer/tests/test_hltb_detail_extract_comp100_avg_and_high.py b/steam_backlog_enforcer/tests/test_hltb_detail_extract_comp100_avg_and_high.py
new file mode 100644
index 0000000..6c59e38
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_detail_extract_comp100_avg_and_high.py
@@ -0,0 +1,68 @@
+"""Tests for HLTB internal helpers, detail fetching, and leisure times — part 3."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from typing_extensions import Self
+
+from steam_backlog_enforcer._hltb_detail import (
+ _extract_comp_100_avg_and_high,
+)
+
+
+class _FakeTextResponse:
+ """Async context manager mimicking aiohttp response for text."""
+
+ def __init__(self, status: int, text: str = "") -> None:
+ """Test init."""
+ self.status = status
+ self._text = text
+
+ async def __aenter__(self) -> Self:
+ """Test aenter."""
+ return self
+
+ async def __aexit__(self, *args: object) -> None:
+ """Test aexit."""
+
+ async def text(self) -> str:
+ """Test text."""
+ return self._text
+
+
+class TestExtractComp100AvgAndHigh:
+ """Tests for _extract_comp_100_avg_and_high."""
+
+ def test_returns_minus_one_for_empty_game_list(self) -> None:
+ """Test returns minus one for empty game list."""
+ assert _extract_comp_100_avg_and_high({"game": []}) == (-1, -1)
+
+ def test_returns_minus_one_for_non_list_game(self) -> None:
+ """Test returns minus one for non list game."""
+ assert _extract_comp_100_avg_and_high({"game": "bad"}) == (-1, -1)
+
+ def test_returns_minus_one_when_game0_not_dict(self) -> None:
+ """Test returns minus one when game0 not dict."""
+ assert _extract_comp_100_avg_and_high({"game": [42]}) == (-1, -1)
+
+ def test_returns_avg_and_high(self) -> None:
+ """Test returns avg and high."""
+ 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:
+ """Test high falls back to avg when zero."""
+ 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:
+ """Test avg zero returns minus one avg."""
+ 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
diff --git a/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times.py b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times.py
new file mode 100644
index 0000000..e05ad78
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times.py
@@ -0,0 +1,154 @@
+"""Tests for HLTB internal helpers, detail fetching, and leisure times — part 3."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from typing_extensions import Self
+
+from steam_backlog_enforcer._hltb_detail import (
+ _fetch_leisure_times,
+)
+from steam_backlog_enforcer._hltb_types import (
+ HLTBResult,
+)
+
+
+class _FakeTextResponse:
+ """Async context manager mimicking aiohttp response for text."""
+
+ def __init__(self, status: int, text: str = "") -> None:
+ """Test init."""
+ self.status = status
+ self._text = text
+
+ async def __aenter__(self) -> Self:
+ """Test aenter."""
+ return self
+
+ async def __aexit__(self, *args: object) -> None:
+ """Test aexit."""
+
+ async def text(self) -> str:
+ """Test text."""
+ return self._text
+
+
+class TestFetchLeisureTimes:
+ """Tests for _fetch_leisure_times."""
+
+ def test_updates_cache(self) -> None:
+ """Test updates cache."""
+ 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(
+ "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:
+ """Test no valid results."""
+ 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:
+ """Test empty results."""
+ cache: dict[int, float] = {}
+ asyncio.run(_fetch_leisure_times([], cache, {}, None))
+ assert not cache
+
+ def test_detail_returns_none(self) -> None:
+ """Test detail returns 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(
+ "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:
+ """Test negative leisure."""
+ 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(
+ "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:
+ """Test with progress cb."""
+ 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(
+ "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()
diff --git a/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times_save_interval.py b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times_save_interval.py
new file mode 100644
index 0000000..d30a0b8
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times_save_interval.py
@@ -0,0 +1,204 @@
+"""Tests for HLTB internal helpers, detail fetching, and leisure times — part 3."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+from unittest.mock import AsyncMock, patch
+
+from typing_extensions import Self
+
+from steam_backlog_enforcer._hltb_detail import (
+ _fetch_leisure_times,
+)
+from steam_backlog_enforcer._hltb_types import (
+ _SAVE_INTERVAL,
+ HLTBResult,
+ _HLTBExtras,
+)
+
+
+class _FakeTextResponse:
+ """Async context manager mimicking aiohttp response for text."""
+
+ def __init__(self, status: int, text: str = "") -> None:
+ """Test init."""
+ self.status = status
+ self._text = text
+
+ async def __aenter__(self) -> Self:
+ """Test aenter."""
+ return self
+
+ async def __aexit__(self, *args: object) -> None:
+ """Test aexit."""
+
+ async def text(self) -> str:
+ """Test text."""
+ return self._text
+
+
+class TestFetchLeisureTimesGroup2:
+ """Tests for _fetch_leisure_times."""
+
+ 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(
+ "steam_backlog_enforcer._hltb_detail._fetch_detail_one",
+ new_callable=AsyncMock,
+ return_value=game_data,
+ ),
+ patch("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:
+ """Test dlc detail overrides relationship fallback."""
+ 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(
+ "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:
+ """Test missing dlc detail keeps relationship fallback."""
+ 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(
+ "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(
+ "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(
+ "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
+
+ def test_count_comp_from_detail_page_stored_in_extras(self) -> None:
+ """Line 254: extras.count_comp updated when game detail has count_comp > 0."""
+ 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, "count_comp": 99}],
+ "relationships": [],
+ }
+ cache: dict[int, float] = {}
+ extras = _HLTBExtras()
+ with patch(
+ "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.count_comp[440] == 99
diff --git a/steam_backlog_enforcer/tests/test_hltb_detail_process_game_detail.py b/steam_backlog_enforcer/tests/test_hltb_detail_process_game_detail.py
new file mode 100644
index 0000000..1e95c2f
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_detail_process_game_detail.py
@@ -0,0 +1,127 @@
+"""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
+
+import aiohttp
+from typing_extensions import Self
+
+from steam_backlog_enforcer._hltb_detail import (
+ _fetch_detail_one,
+ _process_game_detail,
+)
+
+
+class _FakeTextResponse:
+ """Async context manager mimicking aiohttp response for text."""
+
+ def __init__(self, status: int, text: str = "") -> None:
+ """Test init."""
+ self.status = status
+ self._text = text
+
+ async def __aenter__(self) -> Self:
+ """Test aenter."""
+ return self
+
+ async def __aexit__(self, *args: object) -> None:
+ """Test aexit."""
+
+ async def text(self) -> str:
+ """Test text."""
+ return self._text
+
+
+class TestProcessGameDetail:
+ """Tests for _process_game_detail."""
+
+ def test_returns_leisure_rush_and_l100(self) -> None:
+ """Test returns leisure rush and l100."""
+ 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:
+ """Test negative leisure when no data."""
+ 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:
+ """Test rush includes dlc fallback."""
+ 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:
+ """Test l100 uses dlc override."""
+ 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 TestFetchDetailOne:
+ """Tests for _fetch_detail_one."""
+
+ def test_success(self) -> None:
+ """Test success."""
+ game_data: dict[str, Any] = {
+ "game": [{"comp_100_h": 21243}],
+ "relationships": [],
+ }
+ next_data = {"props": {"pageProps": {"game": {"data": game_data}}}}
+ html = (
+ '"
+ )
+ 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:
+ """Test non 200."""
+ 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:
+ """Test client error."""
+ 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:
+ """Test parse failure."""
+ resp = _FakeTextResponse(200, "no script")
+ session = MagicMock()
+ session.get = MagicMock(return_value=resp)
+ result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
+ assert result is None
diff --git a/steam_backlog_enforcer/tests/test_hltb_fetch_hltb_detail_missing.py b/steam_backlog_enforcer/tests/test_hltb_fetch_hltb_detail_missing.py
new file mode 100644
index 0000000..b336127
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_fetch_hltb_detail_missing.py
@@ -0,0 +1,176 @@
+"""Tests for hltb module — part 2 (missing coverage)."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from unittest.mock import patch
+
+from typing_extensions import Self
+
+from steam_backlog_enforcer.hltb import (
+ fetch_hltb_detail_missing,
+)
+
+if TYPE_CHECKING:
+ from steam_backlog_enforcer._hltb_types import _HLTBExtras
+
+PKG = "steam_backlog_enforcer.hltb"
+
+
+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 TestFetchHltbDetailMissing:
+ """Tests for fetch_hltb_detail_missing."""
+
+ def test_no_missing_returns_zero(self) -> None:
+ """All games in rush cache with known game IDs → early return."""
+ with (
+ patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}),
+ patch(f"{PKG}.load_hltb_game_id_cache", return_value={440: 12345}),
+ 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]:
+ """Test add rush."""
+ 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:
+ """Test capture save."""
+ 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]:
+ """Test add hours and rush."""
+ 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:
+ """Test capture save."""
+ 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
+
+ def test_id_only_missing_logs_else_branch(self) -> None:
+ """Rush data present but game ID missing → else branch in log selection."""
+ with (
+ patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}),
+ patch(f"{PKG}.load_hltb_cache", return_value={440: 15.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"),
+ patch(f"{PKG}.save_hltb_cache"),
+ patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]),
+ ):
+ result = fetch_hltb_detail_missing([(440, "TF2")])
+ assert result == 0
diff --git a/steam_backlog_enforcer/tests/test_hltb_part2.py b/steam_backlog_enforcer/tests/test_hltb_part2.py
index b227df8..953156b 100644
--- a/steam_backlog_enforcer/tests/test_hltb_part2.py
+++ b/steam_backlog_enforcer/tests/test_hltb_part2.py
@@ -2,20 +2,14 @@
from __future__ import annotations
-import asyncio
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from typing_extensions import Self
-from steam_backlog_enforcer._hltb_search import _AuthInfo
from 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,
)
@@ -26,10 +20,23 @@ if TYPE_CHECKING:
PKG = "steam_backlog_enforcer.hltb"
+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 TestFetchHltbTimesCached:
"""Tests for fetch_hltb_times_cached."""
def test_all_cached(self) -> None:
+ """Test all cached."""
with (
patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}),
):
@@ -37,6 +44,7 @@ class TestFetchHltbTimesCached:
assert result == {440: 50.0}
def test_uncached_games_fetched(self) -> None:
+ """Test uncached games fetched."""
with (
patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}),
patch(f"{PKG}.fetch_hltb_times") as mock_fetch,
@@ -51,6 +59,7 @@ class TestFetchHltbTimesCached:
progress_cb: object = None,
extras: _HLTBExtras | None = None,
) -> list[object]:
+ """Test add to cache."""
if cache is not None:
cache[730] = 20.0
if polls is not None:
@@ -68,6 +77,7 @@ class TestFetchHltbTimesCached:
mock_save.assert_called_once()
def test_uncached_with_progress_cb(self) -> None:
+ """Test uncached with progress cb."""
cb = MagicMock()
with (
patch(f"{PKG}.load_hltb_cache", return_value={}),
@@ -109,6 +119,7 @@ class TestFetchHltbTimesCached:
progress_cb: object = None,
extras: _HLTBExtras | None = None,
) -> list[object]:
+ """Test add found."""
if cache is not None:
cache[440] = 50.0
cache[730] = -1
@@ -132,6 +143,7 @@ class TestGetHltbSubmitUrl:
"""Tests for get_hltb_submit_url."""
def test_found(self) -> None:
+ """Test found."""
mock_result = HLTBResult(
app_id=0,
game_name="TF2",
@@ -144,11 +156,13 @@ class TestGetHltbSubmitUrl:
assert url == f"{HLTB_BASE_URL}/submit/game/12345"
def test_not_found_empty(self) -> None:
+ """Test not found empty."""
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:
+ """Test not found no id."""
mock_result = HLTBResult(
app_id=0,
game_name="TF2",
@@ -159,251 +173,3 @@ class TestGetHltbSubmitUrl:
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_batch_confidence_only_with_hp_key_and_prepopulated_count_comp(
- 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="hpk", hp_val="hpv"),
- ),
- 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={1: 42},
- ),
- )
- 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 with known game IDs → early return."""
- with (
- patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}),
- patch(f"{PKG}.load_hltb_game_id_cache", return_value={440: 12345}),
- 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
-
- def test_id_only_missing_logs_else_branch(self) -> None:
- """Rush data present but game ID missing → else branch in log selection."""
- with (
- patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}),
- patch(f"{PKG}.load_hltb_cache", return_value={440: 15.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"),
- patch(f"{PKG}.save_hltb_cache"),
- patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]),
- ):
- result = fetch_hltb_detail_missing([(440, "TF2")])
- assert result == 0
diff --git a/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry.py b/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry.py
new file mode 100644
index 0000000..306cfc5
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry.py
@@ -0,0 +1,121 @@
+"""Tests for hltb module."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from steam_backlog_enforcer._hltb_search import (
+ _pick_best_hltb_entry,
+)
+
+
+class TestPickBestHltbEntry:
+ """Tests for _pick_best_hltb_entry."""
+
+ def test_empty(self) -> None:
+ """Test empty."""
+ assert _pick_best_hltb_entry("game", []) is None
+
+ def test_single(self) -> None:
+ """Test single."""
+ 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:
+ """Test prefers full edition colon."""
+ 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:
+ """Test prefers full edition dash."""
+ 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:
+ """Test falls back to highest similarity."""
+ 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:
+ """Test prefers non dlc when available."""
+ 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"
diff --git a/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry_alias_exact_match_beats_spinoff.py b/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry_alias_exact_match_beats_spinoff.py
new file mode 100644
index 0000000..98bacdb
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry_alias_exact_match_beats_spinoff.py
@@ -0,0 +1,123 @@
+"""Tests for hltb module."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from steam_backlog_enforcer._hltb_search import (
+ _pick_best_hltb_entry,
+)
+
+
+class TestPickBestHltbEntryGroup2:
+ """Tests for _pick_best_hltb_entry."""
+
+ 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"
diff --git a/steam_backlog_enforcer/tests/test_hltb_search.py b/steam_backlog_enforcer/tests/test_hltb_search.py
index a0aa2c2..02cbf8b 100644
--- a/steam_backlog_enforcer/tests/test_hltb_search.py
+++ b/steam_backlog_enforcer/tests/test_hltb_search.py
@@ -4,19 +4,15 @@ from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import MagicMock
import aiohttp
from typing_extensions import Self
from steam_backlog_enforcer._hltb_search import (
- _fetch_batch,
_search_one,
_SearchCtx,
)
-from steam_backlog_enforcer._hltb_types import (
- _SAVE_INTERVAL,
-)
if TYPE_CHECKING:
from collections.abc import Callable
@@ -26,16 +22,19 @@ class _FakeResponse:
"""Async context manager mimicking aiohttp response."""
def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None:
+ """Test init."""
self.status = status
self._json_data = json_data or {}
async def __aenter__(self) -> Self:
+ """Test aenter."""
return self
async def __aexit__(self, *args: object) -> None:
- pass
+ """Test aexit."""
async def json(self) -> dict[str, Any]:
+ """Test json."""
return self._json_data
@@ -66,6 +65,7 @@ class TestSearchOne:
"""Tests for _search_one."""
def test_found(self) -> None:
+ """Test found."""
resp = _FakeResponse(
200,
{
@@ -105,6 +105,7 @@ class TestSearchOne:
assert 440 not in ctx.hltb_game_id
def test_not_found(self) -> None:
+ """Test not found."""
resp = _FakeResponse(200, {"data": []})
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
@@ -112,6 +113,7 @@ class TestSearchOne:
assert ctx.cache[440] == -1
def test_error(self) -> None:
+ """Test error."""
session = MagicMock()
session.post.side_effect = aiohttp.ClientError("fail")
ctx = _make_ctx(session)
@@ -119,12 +121,14 @@ class TestSearchOne:
assert result is None
def test_non_200(self) -> None:
+ """Test non 200."""
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:
+ """Test fallback name without year suffix."""
session = MagicMock()
session.post.side_effect = [
_FakeResponse(200, {"data": []}),
@@ -156,6 +160,7 @@ class TestSearchOne:
assert session.post.call_count == 2
def test_with_progress_cb(self) -> None:
+ """Test with progress cb."""
resp = _FakeResponse(200, {"data": []})
cb = MagicMock()
ctx = _make_ctx(_make_session(resp), progress_cb=cb)
@@ -163,6 +168,7 @@ class TestSearchOne:
cb.assert_called_once()
def test_low_similarity_skipped(self) -> None:
+ """Test low similarity skipped."""
resp = _FakeResponse(
200,
{
@@ -179,170 +185,3 @@ class TestSearchOne:
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("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()
-
- def test_colon_strip_fallback_rejects_cross_franchise_match(self) -> None:
- """Colon-stripped fallback must not match a different franchise loosely.
-
- "Vox Populi: Poland 2023" stripped to "Vox Populi" should NOT match
- "Vox Populi Vox Dei 2" (different game, low-similarity entry).
- """
- empty_resp = _FakeResponse(200, {"data": []})
- loose_resp = _FakeResponse(
- 200,
- {
- "data": [
- {
- "game_name": "Vox Populi Vox Dei 2",
- "game_alias": "",
- "game_type": "game",
- "comp_100": 14400,
- "comp_100_count": 9,
- "count_comp": 57,
- "game_id": 99999,
- }
- ]
- },
- )
- session = MagicMock()
- session.post.side_effect = [empty_resp, loose_resp]
- ctx = _make_ctx(session)
- result = asyncio.run(
- _search_one(asyncio.Semaphore(1), ctx, 2590810, "Vox Populi: Poland 2023")
- )
- assert result is None
-
- def test_colon_strip_fallback_accepts_full_edition(self) -> None:
- """Colon-stripped fallback must still match when the HLTB entry is a
- full edition of the stripped name (name starts with stripped + ':').
- """
- empty_resp = _FakeResponse(200, {"data": []})
- full_edition_resp = _FakeResponse(
- 200,
- {
- "data": [
- {
- "game_name": "Batman: Arkham Asylum",
- "game_alias": "",
- "game_type": "game",
- "comp_100": 144000,
- "comp_100_count": 300,
- "count_comp": 5000,
- "game_id": 11111,
- }
- ]
- },
- )
- session = MagicMock()
- session.post.side_effect = [empty_resp, full_edition_resp]
- ctx = _make_ctx(session)
- result = asyncio.run(
- _search_one(asyncio.Semaphore(1), ctx, 35140, "Batman: Arkham Asylum")
- )
- assert result is not None
- assert result.game_name == "Batman: Arkham Asylum"
-
-
-class TestFetchBatchHltb:
- """Tests for _fetch_batch (the hltb version)."""
-
- def test_no_auth(self) -> None:
- with (
- patch(
- "steam_backlog_enforcer._hltb_search._get_hltb_search_url",
- return_value="https://example.com",
- ),
- patch(
- "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."""
diff --git a/steam_backlog_enforcer/tests/test_hltb_search_extract_leisure_hours.py b/steam_backlog_enforcer/tests/test_hltb_search_extract_leisure_hours.py
new file mode 100644
index 0000000..ce9e10e
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_search_extract_leisure_hours.py
@@ -0,0 +1,160 @@
+"""Tests for HLTB search entry picking, page parsing, and leisure extraction."""
+
+from __future__ import annotations
+
+from typing import Any
+from unittest.mock import MagicMock
+
+from typing_extensions import Self
+
+from steam_backlog_enforcer._hltb_detail import (
+ _extract_leisure_hours,
+)
+from steam_backlog_enforcer._hltb_search import (
+ _build_search_variants,
+)
+
+
+class _FakeResponse:
+ """Async context manager mimicking aiohttp response."""
+
+ def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None:
+ """Test init."""
+ self.status = status
+ self._json_data = json_data or {}
+
+ async def __aenter__(self) -> Self:
+ """Test aenter."""
+ return self
+
+ async def __aexit__(self, *args: object) -> None:
+ """Test aexit."""
+
+ async def json(self) -> dict[str, Any]:
+ """Test json."""
+ return self._json_data
+
+
+def _make_session(resp: _FakeResponse) -> MagicMock:
+ session = MagicMock()
+ session.post.return_value = resp
+ return session
+
+
+class TestExtractLeisureHours:
+ """Tests for _extract_leisure_hours."""
+
+ def test_leisure_time_only(self) -> None:
+ """Test leisure time only."""
+ 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:
+ """Test leisure with dlc."""
+ 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:
+ """Test fallback to comp 100."""
+ 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:
+ """Test no game data."""
+ assert _extract_leisure_hours({"game": [], "relationships": []}) == -1
+
+ def test_zero_leisure(self) -> None:
+ """Test zero leisure."""
+ 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:
+ """Test no game key."""
+ assert _extract_leisure_hours({"relationships": []}) == -1
+
+ def test_non_dlc_relationship_ignored(self) -> None:
+ """Test non dlc relationship ignored."""
+ 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:
+ """Test dlc zero comp 100 skipped."""
+ 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:
+ """Test negative leisure."""
+ 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:
+ """Test string numeric fields."""
+ 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:
+ """Test bad string falls back to comp 100."""
+ 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:
+ """Test relationships not list."""
+ 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"
+ """Test subtitle with edition strips edition from subtitle part."""
+ 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
+ """Test no subtitle skips edition strip."""
+ variants = _build_search_variants("Portal 2")
+ assert "Portal 2" in variants
diff --git a/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py b/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py
new file mode 100644
index 0000000..657b3e0
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py
@@ -0,0 +1,84 @@
+"""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
+
+from typing_extensions import Self
+
+from steam_backlog_enforcer._hltb_search import (
+ _fetch_batch,
+ _SearchCtx,
+)
+
+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:
+ """Test init."""
+ self.status = status
+ self._json_data = json_data or {}
+
+ async def __aenter__(self) -> Self:
+ """Test aenter."""
+ return self
+
+ async def __aexit__(self, *args: object) -> None:
+ """Test aexit."""
+
+ async def json(self) -> dict[str, Any]:
+ """Test json."""
+ 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 TestFetchBatchHltb:
+ """Tests for _fetch_batch (the hltb version)."""
+
+ def test_no_auth(self) -> None:
+ """Test no auth."""
+ with (
+ patch(
+ "steam_backlog_enforcer._hltb_search._get_hltb_search_url",
+ return_value="https://example.com",
+ ),
+ patch(
+ "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."""
diff --git a/steam_backlog_enforcer/tests/test_hltb_search_part2.py b/steam_backlog_enforcer/tests/test_hltb_search_part2.py
index d890afa..3538ef7 100644
--- a/steam_backlog_enforcer/tests/test_hltb_search_part2.py
+++ b/steam_backlog_enforcer/tests/test_hltb_search_part2.py
@@ -10,11 +10,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
from typing_extensions import Self
from steam_backlog_enforcer._hltb_detail import (
- _extract_leisure_hours,
_parse_game_page,
)
from steam_backlog_enforcer._hltb_search import (
- _build_search_variants,
_fetch_batch,
_pick_best_hltb_entry,
)
@@ -28,16 +26,19 @@ class _FakeResponse:
"""Async context manager mimicking aiohttp response."""
def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None:
+ """Test init."""
self.status = status
self._json_data = json_data or {}
async def __aenter__(self) -> Self:
+ """Test aenter."""
return self
async def __aexit__(self, *args: object) -> None:
- pass
+ """Test aexit."""
async def json(self) -> dict[str, Any]:
+ """Test json."""
return self._json_data
@@ -51,6 +52,7 @@ class TestPickBestEntry:
"""Tests for exact-vs-extended entry choice logic."""
def test_prefers_exact_over_low_confidence_modded_extended(self) -> None:
+ """Test prefers exact over low confidence modded extended."""
exact = (
{
"game_name": "Celeste",
@@ -79,6 +81,7 @@ class TestPickBestEntry:
assert best[0]["game_name"] == "Celeste"
def test_prefers_extended_when_confident_and_longer(self) -> None:
+ """Test prefers extended when confident and longer."""
exact_demo = (
{
"game_name": "FAITH",
@@ -107,6 +110,7 @@ class TestPickBestEntry:
assert best[0]["game_name"] == "FAITH: The Unholy Trinity"
def test_with_auth(self) -> None:
+ """Test with auth."""
auth = _AuthInfo("token123", "ign_x", "ff")
with (
patch(
@@ -138,6 +142,7 @@ class TestPickBestEntry:
assert len(results) == 1
def test_with_auth_no_hp(self) -> None:
+ """Test with auth no hp."""
auth = _AuthInfo("tok123")
with (
patch(
@@ -163,6 +168,7 @@ class TestPickBestEntry:
assert results == []
def test_filters_none_results(self) -> None:
+ """Test filters none results."""
auth = _AuthInfo("tok123")
with (
patch(
@@ -192,6 +198,7 @@ class TestParseGamePage:
"""Tests for _parse_game_page."""
def test_valid_html(self) -> None:
+ """Test valid html."""
game_data: dict[str, Any] = {
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
"relationships": [],
@@ -207,119 +214,17 @@ class TestParseGamePage:
assert _parse_game_page(html) == game_data
def test_no_script_tag(self) -> None:
+ """Test no script tag."""
assert _parse_game_page("") is None
def test_bad_json(self) -> None:
+ """Test bad json."""
html = ''
assert _parse_game_page(html) is None
def test_missing_keys(self) -> None:
+ """Test missing keys."""
html = (
''
)
assert _parse_game_page(html) is None
-
-
-class TestExtractLeisureHours:
- """Tests for _extract_leisure_hours."""
-
- def test_leisure_time_only(self) -> None:
- data: dict[str, Any] = {
- "game": [{"comp_100_h": 21243, "comp_100": 6800}],
- "relationships": [],
- }
- assert _extract_leisure_hours(data) == round(21243 / 3600, 2)
-
- def test_leisure_with_dlc(self) -> None:
- data: dict[str, Any] = {
- "game": [{"comp_100_h": 21243, "comp_100": 6800}],
- "relationships": [
- {"game_type": "dlc", "comp_100": 12298},
- {"game_type": "dlc", "comp_100": 3600},
- ],
- }
- assert _extract_leisure_hours(data) == round((21243 + 12298 + 3600) / 3600, 2)
-
- def test_fallback_to_comp_100(self) -> None:
- data: dict[str, Any] = {
- "game": [{"comp_100": 7200}],
- "relationships": [],
- }
- assert _extract_leisure_hours(data) == round(7200 / 3600, 2)
-
- def test_no_game_data(self) -> None:
- assert _extract_leisure_hours({"game": [], "relationships": []}) == -1
-
- def test_zero_leisure(self) -> None:
- data: dict[str, Any] = {
- "game": [{"comp_100_h": 0, "comp_100": 0}],
- "relationships": [],
- }
- assert _extract_leisure_hours(data) == -1
-
- def test_no_game_key(self) -> None:
- assert _extract_leisure_hours({"relationships": []}) == -1
-
- def test_non_dlc_relationship_ignored(self) -> None:
- data: dict[str, Any] = {
- "game": [{"comp_100_h": 3600}],
- "relationships": [
- {"game_type": "game", "comp_100": 9999},
- {"game_type": "dlc", "comp_100": 1800},
- ],
- }
- assert _extract_leisure_hours(data) == round((3600 + 1800) / 3600, 2)
-
- def test_dlc_zero_comp_100_skipped(self) -> None:
- data: dict[str, Any] = {
- "game": [{"comp_100_h": 3600}],
- "relationships": [
- {"game_type": "dlc", "comp_100": 0},
- ],
- }
- assert _extract_leisure_hours(data) == round(3600 / 3600, 2)
-
- def test_negative_leisure(self) -> None:
- data: dict[str, Any] = {
- "game": [{"comp_100_h": -1, "comp_100": -1}],
- "relationships": [],
- }
- assert _extract_leisure_hours(data) == -1
-
- def test_string_numeric_fields(self) -> None:
- data: dict[str, Any] = {
- "game": [{"comp_100_h": "7200", "comp_100": "3600"}],
- "relationships": [{"game_type": "dlc", "game_id": "1", "comp_100": "1800"}],
- }
- assert _extract_leisure_hours(data) == round((7200 + 1800) / 3600, 2)
-
- def test_bad_string_falls_back_to_comp_100(self) -> None:
- data: dict[str, Any] = {
- "game": [{"comp_100_h": "bad", "comp_100": "3600"}],
- "relationships": [],
- }
- assert _extract_leisure_hours(data) == 1.0
-
- def test_relationships_not_list(self) -> None:
- data: dict[str, Any] = {
- "game": [{"comp_100_h": 3600}],
- "relationships": "not-a-list",
- }
- assert _extract_leisure_hours(data) == 1.0
-
-
-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
diff --git a/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py b/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py
new file mode 100644
index 0000000..f40c561
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py
@@ -0,0 +1,215 @@
+"""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 MagicMock, patch
+
+from typing_extensions import Self
+
+from steam_backlog_enforcer._hltb_search import (
+ _search_one,
+ _SearchCtx,
+)
+from 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:
+ """Test init."""
+ self.status = status
+ self._json_data = json_data or {}
+
+ async def __aenter__(self) -> Self:
+ """Test aenter."""
+ return self
+
+ async def __aexit__(self, *args: object) -> None:
+ """Test aexit."""
+
+ async def json(self) -> dict[str, Any]:
+ """Test json."""
+ 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 TestSearchOneGroup2:
+ """Tests for _search_one."""
+
+ def test_zero_comp_100_skipped(self) -> None:
+ """Test zero comp 100 skipped."""
+ 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:
+ """Test alias match."""
+ 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:
+ """Test full edition colon."""
+ 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:
+ """Test full edition dash."""
+ 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("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()
+
+ def test_colon_strip_fallback_rejects_cross_franchise_match(self) -> None:
+ """Colon-stripped fallback must not match a different franchise loosely.
+
+ "Vox Populi: Poland 2023" stripped to "Vox Populi" should NOT match
+ "Vox Populi Vox Dei 2" (different game, low-similarity entry).
+ """
+ empty_resp = _FakeResponse(200, {"data": []})
+ loose_resp = _FakeResponse(
+ 200,
+ {
+ "data": [
+ {
+ "game_name": "Vox Populi Vox Dei 2",
+ "game_alias": "",
+ "game_type": "game",
+ "comp_100": 14400,
+ "comp_100_count": 9,
+ "count_comp": 57,
+ "game_id": 99999,
+ }
+ ]
+ },
+ )
+ session = MagicMock()
+ session.post.side_effect = [empty_resp, loose_resp]
+ ctx = _make_ctx(session)
+ result = asyncio.run(
+ _search_one(asyncio.Semaphore(1), ctx, 2590810, "Vox Populi: Poland 2023")
+ )
+ assert result is None
+
+ def test_colon_strip_fallback_accepts_full_edition(self) -> None:
+ """Colon-stripped fallback must still match when the HLTB entry is a
+ full edition of the stripped name (name starts with stripped + ':').
+ """
+ empty_resp = _FakeResponse(200, {"data": []})
+ full_edition_resp = _FakeResponse(
+ 200,
+ {
+ "data": [
+ {
+ "game_name": "Batman: Arkham Asylum",
+ "game_alias": "",
+ "game_type": "game",
+ "comp_100": 144000,
+ "comp_100_count": 300,
+ "count_comp": 5000,
+ "game_id": 11111,
+ }
+ ]
+ },
+ )
+ session = MagicMock()
+ session.post.side_effect = [empty_resp, full_edition_resp]
+ ctx = _make_ctx(session)
+ result = asyncio.run(
+ _search_one(asyncio.Semaphore(1), ctx, 35140, "Batman: Arkham Asylum")
+ )
+ assert result is not None
+ assert result.game_name == "Batman: Arkham Asylum"
diff --git a/steam_backlog_enforcer/tests/test_polls_tracking.py b/steam_backlog_enforcer/tests/test_polls_tracking.py
index 19882d3..b9d9bd2 100644
--- a/steam_backlog_enforcer/tests/test_polls_tracking.py
+++ b/steam_backlog_enforcer/tests/test_polls_tracking.py
@@ -6,7 +6,6 @@ import json
from typing import TYPE_CHECKING
from unittest.mock import patch
-from steam_backlog_enforcer import _cmd_done
from steam_backlog_enforcer._hltb_types import (
HLTBResult,
_HLTBExtras,
@@ -27,10 +26,19 @@ _CMD = "steam_backlog_enforcer._cmd_done"
_SCAN = "steam_backlog_enforcer.scanning"
+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 TestCacheSchema:
"""Tests for the new cache schema and back-compat migration."""
def test_legacy_float_migrates(self, tmp_path: Path) -> None:
+ """Test legacy float migrates."""
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):
@@ -39,6 +47,7 @@ class TestCacheSchema:
assert load_hltb_count_comp_cache() == {440: 0}
def test_new_dict_schema(self, tmp_path: Path) -> None:
+ """Test new dict schema."""
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"440": {"hours": 10.5, "polls": 7, "count_comp": 20}}),
@@ -50,6 +59,7 @@ class TestCacheSchema:
assert load_hltb_count_comp_cache() == {440: 20}
def test_invalid_app_id_skipped(self, tmp_path: Path) -> None:
+ """Test invalid app id skipped."""
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"notanint": 1.0, "440": 5.0}), encoding="utf-8"
@@ -57,13 +67,15 @@ class TestCacheSchema:
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:
+ def test_unparsable_value_skipped(self, tmp_path: Path) -> None:
+ """Test unparsable value skipped."""
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:
+ """Test save with polls roundtrip."""
cache_file = tmp_path / "hltb_cache.json"
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
@@ -83,6 +95,7 @@ class TestCacheSchema:
}
def test_save_without_polls_defaults_zero(self, tmp_path: Path) -> None:
+ """Test save without polls defaults zero."""
cache_file = tmp_path / "hltb_cache.json"
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
@@ -113,12 +126,16 @@ class TestCacheSchema:
class TestHltbResultPolls:
+ """Tests for Hltb Result Polls."""
+
def test_default_zero(self) -> None:
+ """Test default zero."""
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:
+ """Test explicit."""
r = HLTBResult(
app_id=1,
game_name="x",
@@ -132,7 +149,10 @@ class TestHltbResultPolls:
class TestGameInfoPolls:
+ """Tests for Game Info Polls."""
+
def test_snapshot_roundtrip(self) -> None:
+ """Test snapshot roundtrip."""
g = GameInfo(
app_id=1,
name="X",
@@ -150,6 +170,7 @@ class TestGameInfoPolls:
assert restored.count_comp == 20
def test_snapshot_missing_field_defaults(self) -> None:
+ """Test snapshot missing field defaults."""
snap = {
"app_id": 1,
"name": "X",
@@ -159,224 +180,3 @@ class TestGameInfoPolls:
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)
diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py b/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py
new file mode 100644
index 0000000..8ae5726
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py
@@ -0,0 +1,130 @@
+"""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 steam_backlog_enforcer import _cmd_done
+from steam_backlog_enforcer.config import State
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_TYPES = "steam_backlog_enforcer._hltb_types"
+_CMD = "steam_backlog_enforcer._cmd_done"
+_SCAN = "steam_backlog_enforcer.scanning"
+
+
+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:
+ """Tests for Backfill Polls For Finished."""
+
+ def test_no_missing_returns_existing(self, tmp_path: Path) -> None:
+ """Test no missing returns existing."""
+ 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:
+ """Test no snapshot no missing."""
+ 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:
+ """Test missing triggers fetch."""
+ 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]:
+ """Test fake fetch."""
+ 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:
+ """Test extra app id with zero polls added."""
+ 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]:
+ """Test fake fetch."""
+ 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:
+ """Test preserves prior hours on miss."""
+ 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).
+ """Test fake fetch."""
+ 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
diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_part2.py b/steam_backlog_enforcer/tests/test_polls_tracking_part2.py
index 6536b8e..cf6e5a3 100644
--- a/steam_backlog_enforcer/tests/test_polls_tracking_part2.py
+++ b/steam_backlog_enforcer/tests/test_polls_tracking_part2.py
@@ -4,9 +4,9 @@ from __future__ import annotations
import json
from typing import TYPE_CHECKING
-from unittest.mock import MagicMock, patch
+from unittest.mock import patch
-from steam_backlog_enforcer import _cmd_done, _scanning_confidence, scanning
+from steam_backlog_enforcer import _scanning_confidence, scanning
from steam_backlog_enforcer.config import State
from steam_backlog_enforcer.steam_api import GameInfo
@@ -28,8 +28,11 @@ def _state(finished: list[int], current: int | None = None) -> State:
class TestScanningPollsIntegration:
+ """Tests for Scanning Polls Integration."""
+
def test_do_scan_kept_assignment_reports(self) -> None:
# Targeted test for scanning's `else` branch that prints CURRENT.
+ """Test do scan kept assignment reports."""
echoed: list[str] = []
games = [
GameInfo(
@@ -56,6 +59,7 @@ class TestScanningPollsIntegration:
mock_report.assert_called_once()
def test_report_poll_confidence_new_low(self) -> None:
+ """Test report poll confidence new low."""
echoed: list[str] = []
chosen = GameInfo(
app_id=1,
@@ -90,6 +94,7 @@ class TestScanningPollsIntegration:
assert chosen.comp_100_count == 1
def test_report_poll_confidence_no_history(self) -> None:
+ """Test report poll confidence no history."""
echoed: list[str] = []
chosen = GameInfo(
app_id=1,
@@ -112,6 +117,7 @@ class TestScanningPollsIntegration:
assert any("HLTB confidence: 4" in s for s in echoed)
def test_scanning_backfill_no_missing(self, tmp_path: Path) -> None:
+ """Test scanning backfill no missing."""
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"2": {"hours": 1.0, "polls": 5}}), encoding="utf-8"
@@ -132,12 +138,14 @@ class TestScanningPollsIntegration:
assert result == {2: 5}
def test_scanning_backfill_with_missing(self, tmp_path: Path) -> None:
+ """Test scanning backfill with missing."""
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]:
+ """Test fake fetch."""
data = json.loads(cache_file.read_text(encoding="utf-8"))
for aid, _name in games:
data[str(aid)] = {"hours": 3.0, "polls": 8}
@@ -164,12 +172,14 @@ class TestScanningPollsIntegration:
assert result == {2: 8}
def test_scanning_backfill_preserves_hours_on_miss(self, tmp_path: Path) -> None:
+ """Test scanning backfill preserves hours on miss."""
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]:
+ """Test fake fetch."""
data = json.loads(cache_file.read_text(encoding="utf-8"))
for aid, _name in games:
data[str(aid)] = {"hours": -1, "polls": 0}
@@ -195,223 +205,3 @@ class TestScanningPollsIntegration:
)
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 steam_backlog_enforcer.config import Config
- from steam_backlog_enforcer.scanning import do_scan
-
- other = GameInfo(
- app_id=999,
- name="Other",
- total_achievements=10,
- unlocked_achievements=5,
- playtime_minutes=0,
- )
-
- mock_client = MagicMock()
- mock_client.build_game_list.return_value = [other]
- with (
- patch(f"{_SCAN}.SteamAPIClient", return_value=mock_client),
- patch(f"{_SCAN}.fetch_hltb_times_cached", return_value={999: 10.0}),
- patch(f"{_SCAN}.save_snapshot"),
- patch(f"{_SCAN}.pick_next_game") as mock_pick,
- patch(f"{_SCAN}._echo"),
- patch(f"{_SCAN}._report_poll_confidence") as mock_report,
- ):
- config = Config(steam_api_key="k", steam_id="i")
- state = State(current_app_id=440) # not in games
- do_scan(config, state)
- mock_pick.assert_not_called()
- mock_report.assert_not_called()
-
- def test_cmd_done_no_finished_history_chosen_has_polls(self) -> None:
- """Covers _cmd_done.py 100->103: no finished history, chosen has >0 polls."""
- echoed: list[str] = []
- with (
- patch(
- f"{_CMD}._backfill_polls_for_finished",
- return_value={1: 7},
- ),
- patch(
- f"{_CMD}.load_snapshot",
- return_value=[
- {"app_id": 1, "name": "Chosen"},
- ],
- ),
- patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
- ):
- _cmd_done._report_assigned_confidence(1, _state([], current=1))
- assert any("HLTB confidence: 7" in s for s in echoed)
- assert not any("NEW LOW" in s for s in echoed)
- assert not any("no polls recorded" in s for s in echoed)
-
- def test_report_poll_confidence_chosen_equals_min(self) -> None:
- """Covers scanning.py 301->304: chosen_polls >= min_polls, no warning."""
- echoed: list[str] = []
- chosen = GameInfo(
- app_id=1,
- name="Chosen",
- total_achievements=10,
- unlocked_achievements=0,
- playtime_minutes=0,
- comp_100_count=5,
- )
- old = GameInfo(
- app_id=2,
- name="Old",
- total_achievements=10,
- unlocked_achievements=10,
- playtime_minutes=0,
- )
- with (
- patch(
- f"{_SCANCONF}._backfill_polls_for_finished",
- return_value={1: 5, 2: 5},
- ),
- patch(
- f"{_SCANCONF}._echo",
- side_effect=lambda *a, **_: echoed.append(a[0]),
- ),
- ):
- scanning._report_poll_confidence(
- chosen, [chosen, old], _state([2], current=1)
- )
- assert not any("NEW LOW" in s for s in echoed)
- assert not any("no polls recorded" in s for s in echoed)
-
- def test_refresh_candidate_confidence_noop_when_present(self) -> None:
- game = GameInfo(
- app_id=1,
- name="Known",
- total_achievements=10,
- unlocked_achievements=1,
- playtime_minutes=0,
- comp_100_count=3,
- count_comp=15,
- )
- with patch(f"{_SCANCONF}.fetch_hltb_confidence_cached") as mock_fetch:
- _scanning_confidence._refresh_candidate_confidence(game)
- mock_fetch.assert_not_called()
-
- def test_refresh_candidate_confidence_backfills_zeroes(
- self, tmp_path: Path
- ) -> None:
- cache_file = tmp_path / "hltb_cache.json"
- cache_file.write_text(
- json.dumps({"1": {"hours": 4.0, "polls": 0, "count_comp": 0}}),
- encoding="utf-8",
- )
- game = GameInfo(
- app_id=1,
- name="NeedsRefresh",
- total_achievements=10,
- unlocked_achievements=1,
- playtime_minutes=0,
- comp_100_count=0,
- count_comp=0,
- )
-
- def fake_fetch(_games: list[tuple[int, str]]) -> dict[int, float]:
- data = json.loads(cache_file.read_text(encoding="utf-8"))
- data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15}
- cache_file.write_text(json.dumps(data), encoding="utf-8")
- return {1: 4.0}
-
- with (
- patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
- patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
- patch(f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch),
- patch(f"{_SCANCONF}._echo"),
- ):
- _scanning_confidence._refresh_candidate_confidence(game)
-
- assert game.comp_100_count == 3
- assert game.count_comp == 15
-
- def test_filter_hltb_confidence_batches_refreshes(self, tmp_path: Path) -> None:
- """Filtering refreshes missing confidence in one batched cache lookup."""
- cache_file = tmp_path / "hltb_cache.json"
- cache_file.write_text(
- json.dumps(
- {
- "1": {"hours": 4.0, "polls": 0, "count_comp": 0},
- "2": {"hours": 5.0, "polls": 0, "count_comp": 0},
- }
- ),
- encoding="utf-8",
- )
- game_a = GameInfo(
- app_id=1,
- name="A",
- total_achievements=10,
- unlocked_achievements=1,
- playtime_minutes=0,
- comp_100_count=0,
- count_comp=0,
- )
- game_b = GameInfo(
- app_id=2,
- name="B",
- total_achievements=10,
- unlocked_achievements=1,
- playtime_minutes=0,
- comp_100_count=0,
- count_comp=0,
- )
-
- def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]:
- assert sorted(games) == [(1, "A"), (2, "B")]
- data = json.loads(cache_file.read_text(encoding="utf-8"))
- data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15}
- data["2"] = {"hours": 5.0, "polls": 3, "count_comp": 15}
- cache_file.write_text(json.dumps(data), encoding="utf-8")
- return {1: 4.0, 2: 5.0}
-
- with (
- patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
- patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
- patch(
- f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch
- ) as mock_fetch,
- patch(f"{_SCANCONF}._echo"),
- ):
- kept = _scanning_confidence._filter_hltb_confident_candidates(
- [game_a, game_b]
- )
-
- assert [game.app_id for game in kept] == [1, 2]
- mock_fetch.assert_called_once()
diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_report_assigned_confidence.py b/steam_backlog_enforcer/tests/test_polls_tracking_report_assigned_confidence.py
new file mode 100644
index 0000000..9819b74
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_polls_tracking_report_assigned_confidence.py
@@ -0,0 +1,146 @@
+"""Tests for HLTB poll-count tracking, schema migration, and confidence display."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer import _cmd_done
+from steam_backlog_enforcer.config import State
+
+_TYPES = "steam_backlog_enforcer._hltb_types"
+_CMD = "steam_backlog_enforcer._cmd_done"
+_SCAN = "steam_backlog_enforcer.scanning"
+
+
+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 TestReportAssignedConfidence:
+ """Tests for Report Assigned Confidence."""
+
+ def test_new_low_warning(self) -> None:
+ """Test new low warning."""
+ 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:
+ """Test zero polls warning with history."""
+ 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:
+ """Test zero polls warning no history."""
+ 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:
+ """Test healthy no warning."""
+ 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:
+ """Test unknown finished uses appid label."""
+ 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).
+ """Test chosen equals min no warning."""
+ 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)
diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls.py b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls.py
new file mode 100644
index 0000000..7b0f820
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls.py
@@ -0,0 +1,109 @@
+"""Tests for HLTB poll-count tracking — scanning integration (part 2)."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+from steam_backlog_enforcer import _cmd_done, scanning
+from steam_backlog_enforcer.config import State
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_TYPES = "steam_backlog_enforcer._hltb_types"
+_CMD = "steam_backlog_enforcer._cmd_done"
+_SCAN = "steam_backlog_enforcer.scanning"
+_SCANCONF = "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 TestScanningPollsIntegrationGroup2:
+ """Tests for Scanning Polls Integration Group2."""
+
+ 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 steam_backlog_enforcer.config import Config
+ from 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)
diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py
new file mode 100644
index 0000000..673b183
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py
@@ -0,0 +1,172 @@
+"""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 patch
+
+from steam_backlog_enforcer import _scanning_confidence, scanning
+from steam_backlog_enforcer.config import State
+from steam_backlog_enforcer.steam_api import GameInfo
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_TYPES = "steam_backlog_enforcer._hltb_types"
+_CMD = "steam_backlog_enforcer._cmd_done"
+_SCAN = "steam_backlog_enforcer.scanning"
+_SCANCONF = "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 TestScanningPollsIntegrationGroup2Group2:
+ """Tests for Scanning Polls Integration Group2."""
+
+ 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:
+ """Test refresh candidate confidence noop when present."""
+ 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:
+ """Test refresh candidate confidence backfills zeroes."""
+ 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]:
+ """Test fake fetch."""
+ 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]:
+ """Test fake fetch."""
+ assert sorted(games) == [(1, "A"), (2, "B")]
+ data = json.loads(cache_file.read_text(encoding="utf-8"))
+ data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15}
+ data["2"] = {"hours": 5.0, "polls": 3, "count_comp": 15}
+ cache_file.write_text(json.dumps(data), encoding="utf-8")
+ return {1: 4.0, 2: 5.0}
+
+ with (
+ patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
+ patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
+ patch(
+ f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch
+ ) as mock_fetch,
+ patch(f"{_SCANCONF}._echo"),
+ ):
+ kept = _scanning_confidence._filter_hltb_confident_candidates(
+ [game_a, game_b]
+ )
+
+ assert [game.app_id for game in kept] == [1, 2]
+ mock_fetch.assert_called_once()
diff --git a/steam_backlog_enforcer/tests/test_protondb.py b/steam_backlog_enforcer/tests/test_protondb.py
index bf3730a..732b022 100644
--- a/steam_backlog_enforcer/tests/test_protondb.py
+++ b/steam_backlog_enforcer/tests/test_protondb.py
@@ -2,23 +2,16 @@
from __future__ import annotations
-import asyncio
import json
from typing import TYPE_CHECKING, Any
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import aiohttp
+from unittest.mock import patch
from 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:
@@ -29,82 +22,102 @@ class TestProtonDBRating:
"""Tests for ProtonDBRating."""
def test_playable_native(self) -> None:
+ """Test playable native."""
r = ProtonDBRating(app_id=1, tier="native")
assert r.is_playable is True
def test_playable_platinum(self) -> None:
+ """Test playable platinum."""
r = ProtonDBRating(app_id=1, tier="platinum")
assert r.is_playable is True
def test_playable_gold(self) -> None:
+ """Test playable gold."""
r = ProtonDBRating(app_id=1, tier="gold")
assert r.is_playable is True
def test_not_playable_silver(self) -> None:
+ """Test not playable silver."""
r = ProtonDBRating(app_id=1, tier="silver")
assert r.is_playable is False
def test_not_playable_bronze(self) -> None:
+ """Test not playable bronze."""
r = ProtonDBRating(app_id=1, tier="bronze")
assert r.is_playable is False
def test_not_playable_borked(self) -> None:
+ """Test not playable borked."""
r = ProtonDBRating(app_id=1, tier="borked")
assert r.is_playable is False
def test_playable_no_data(self) -> None:
+ """Test playable no data."""
r = ProtonDBRating(app_id=1, tier="")
assert r.is_playable is True
def test_playable_pending(self) -> None:
+ """Test playable pending."""
r = ProtonDBRating(app_id=1, tier="pending")
assert r.is_playable is True
def test_gold_trending_silver(self) -> None:
+ """Test gold trending silver."""
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="silver")
assert r.is_playable is True
def test_gold_trending_gold(self) -> None:
+ """Test gold trending gold."""
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="gold")
assert r.is_playable is True
def test_silver_trending_gold(self) -> None:
+ """Test silver trending gold."""
r = ProtonDBRating(app_id=1, tier="silver", trending_tier="gold")
assert r.is_playable is True
def test_gold_no_trending(self) -> None:
+ """Test gold no trending."""
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="")
assert r.is_playable is True
def test_gold_trending_platinum(self) -> None:
+ """Test gold trending platinum."""
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="platinum")
assert r.is_playable is True
def test_gold_trending_unknown(self) -> None:
+ """Test gold trending unknown."""
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="unknown")
assert r.is_playable is False
def test_gold_trending_bronze(self) -> None:
+ """Test gold trending bronze."""
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="bronze")
assert r.is_playable is False
def test_unknown_tier(self) -> None:
+ """Test unknown tier."""
r = ProtonDBRating(app_id=1, tier="unknown_tier")
assert r.is_playable is False
def test_unplayable_reason_no_trending_tier(self) -> None:
+ """Test unplayable reason no trending tier."""
r = ProtonDBRating(app_id=1, tier="borked")
assert "tier<" in r.unplayable_reason
def test_unplayable_reason_for_silver_silver(self) -> None:
+ """Test unplayable reason for silver silver."""
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:
+ """Test unplayable reason for gold bronze."""
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:
+ """Test unplayable reason empty when playable."""
r = ProtonDBRating(app_id=1, tier="gold")
assert r.unplayable_reason == ""
@@ -113,6 +126,7 @@ class TestProtonDBCache:
"""Tests for cache I/O."""
def test_load_cache_exists(self, tmp_path: Path) -> None:
+ """Test load cache exists."""
cache_file = tmp_path / "protondb_cache.json"
cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8")
with patch(
@@ -123,6 +137,7 @@ class TestProtonDBCache:
assert result == {"440": {"tier": "gold"}}
def test_load_cache_missing(self, tmp_path: Path) -> None:
+ """Test load cache missing."""
cache_file = tmp_path / "nonexistent.json"
with patch(
"steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
@@ -131,6 +146,7 @@ class TestProtonDBCache:
assert _load_cache() == {}
def test_save_cache(self, tmp_path: Path) -> None:
+ """Test save cache."""
cache_file = tmp_path / "protondb_cache.json"
config_dir = tmp_path
with (
@@ -148,6 +164,7 @@ class TestRatingConversion:
"""Tests for rating serialization."""
def test_to_dict(self) -> None:
+ """Test to dict."""
r = ProtonDBRating(
app_id=1,
tier="gold",
@@ -161,6 +178,7 @@ class TestRatingConversion:
assert d["total_reports"] == 100
def test_from_cache(self) -> None:
+ """Test from cache."""
data: dict[str, Any] = {
"tier": "silver",
"trending_tier": "bronze",
@@ -172,120 +190,7 @@ class TestRatingConversion:
assert r.trending_tier == "bronze"
def test_from_cache_defaults(self) -> None:
+ """Test from cache defaults."""
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(
- "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(
- "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(
- "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(
- "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
- cache_file,
- ),
- patch("steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir),
- patch(
- "steam_backlog_enforcer.protondb._fetch_batch",
- return_value=[ProtonDBRating(app_id=440, tier="platinum")],
- ),
- ):
- result = fetch_protondb_ratings([440])
- assert result[440].tier == "platinum"
diff --git a/steam_backlog_enforcer/tests/test_protondb_fetch_batch.py b/steam_backlog_enforcer/tests/test_protondb_fetch_batch.py
new file mode 100644
index 0000000..5e8b517
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_protondb_fetch_batch.py
@@ -0,0 +1,89 @@
+"""Tests for protondb module."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from typing import TYPE_CHECKING
+from unittest.mock import AsyncMock, patch
+
+from steam_backlog_enforcer.protondb import (
+ ProtonDBRating,
+ _fetch_batch,
+ fetch_protondb_ratings,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ import aiohttp
+
+
+class TestFetchBatch:
+ """Tests for _fetch_batch."""
+
+ def test_returns_ratings(self) -> None:
+ """Test returns ratings."""
+ rating = ProtonDBRating(app_id=440, tier="gold")
+ with patch(
+ "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:
+ """Test mock fetch one."""
+ return rating if app_id == 440 else None
+
+ with patch(
+ "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:
+ """Test all cached."""
+ cache_file = tmp_path / "protondb_cache.json"
+ cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8")
+ with patch(
+ "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:
+ """Test fetch uncached."""
+ cache_file = tmp_path / "protondb_cache.json"
+ config_dir = tmp_path
+ with (
+ patch(
+ "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
+ cache_file,
+ ),
+ patch("steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir),
+ patch(
+ "steam_backlog_enforcer.protondb._fetch_batch",
+ return_value=[ProtonDBRating(app_id=440, tier="platinum")],
+ ),
+ ):
+ result = fetch_protondb_ratings([440])
+ assert result[440].tier == "platinum"
diff --git a/steam_backlog_enforcer/tests/test_protondb_fetch_one.py b/steam_backlog_enforcer/tests/test_protondb_fetch_one.py
new file mode 100644
index 0000000..a8db27e
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_protondb_fetch_one.py
@@ -0,0 +1,64 @@
+"""Tests for protondb module."""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock
+
+import aiohttp
+
+from steam_backlog_enforcer.protondb import (
+ HTTP_NOT_FOUND,
+ _fetch_one,
+)
+
+
+class TestFetchOne:
+ """Tests for _fetch_one."""
+
+ def test_success(self) -> None:
+ """Test success."""
+ 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:
+ """Test not found."""
+ 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:
+ """Test client error."""
+ 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
diff --git a/steam_backlog_enforcer/tests/test_scanning.py b/steam_backlog_enforcer/tests/test_scanning.py
index f930760..1b253d1 100644
--- a/steam_backlog_enforcer/tests/test_scanning.py
+++ b/steam_backlog_enforcer/tests/test_scanning.py
@@ -10,7 +10,6 @@ from steam_backlog_enforcer.protondb import ProtonDBRating
from steam_backlog_enforcer.scanning import (
_pick_playable_candidate,
do_scan,
- pick_next_game,
)
from steam_backlog_enforcer.steam_api import GameInfo
@@ -41,6 +40,7 @@ class TestDoScan:
"""Tests for do_scan."""
def test_scans_and_picks(self) -> None:
+ """Test scans and picks."""
game = _game(app_id=440, name="TF2", total=10, unlocked=5)
mock_client = MagicMock()
@@ -48,6 +48,7 @@ class TestDoScan:
progress_callback: Callable[..., object] | None = None,
) -> list[GameInfo]:
# Trigger progress callback to cover those lines.
+ """Test build game list."""
if progress_callback:
progress_callback(50, 100)
progress_callback(100, 100)
@@ -83,12 +84,14 @@ class TestDoScan:
mock_pick.assert_called_once()
def test_scan_all_complete(self) -> None:
+ """Test scan all complete."""
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]:
+ """Test build game list."""
if progress_callback:
# current=1, total=2 → not %50 and not ==total → covers False branch
progress_callback(1, 2)
@@ -115,6 +118,7 @@ class TestDoScan:
mock_pick.assert_called_once()
def test_scan_already_assigned(self) -> None:
+ """Test scan already assigned."""
game = _game(app_id=440, total=10, unlocked=5)
mock_client = MagicMock()
mock_client.build_game_list.return_value = [game]
@@ -146,6 +150,7 @@ class TestPickPlayableCandidate:
"""Tests for _pick_playable_candidate."""
def test_finds_playable(self) -> None:
+ """Test finds playable."""
game = _game(app_id=440, name="TF2")
with (
patch(
@@ -161,6 +166,7 @@ class TestPickPlayableCandidate:
assert result.app_id == 440
def test_skips_bad_rating(self) -> None:
+ """Test skips bad rating."""
bad = _game(app_id=1, name="Bad")
good = _game(app_id=2, name="Good")
with (
@@ -178,6 +184,7 @@ class TestPickPlayableCandidate:
assert result.app_id == 2
def test_all_unplayable(self) -> None:
+ """Test all unplayable."""
game = _game(app_id=1, name="Bad")
with (
patch(
@@ -191,6 +198,7 @@ class TestPickPlayableCandidate:
assert _pick_playable_candidate([game]) is None
def test_empty_list(self) -> None:
+ """Test empty list."""
assert _pick_playable_candidate([]) is None
def test_first_in_batch_playable(self) -> None:
@@ -207,238 +215,3 @@ class TestPickPlayableCandidate:
):
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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch("steam_backlog_enforcer._scanning_confidence._echo"),
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=True,
- ),
- patch(
- "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("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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=True,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- return_value=None,
- ),
- patch("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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch("steam_backlog_enforcer._scanning_confidence._echo"),
- patch(
- "steam_backlog_enforcer.scanning.uninstall_other_games",
- return_value=2,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch("steam_backlog_enforcer._scanning_confidence._echo"),
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=False,
- ),
- patch("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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=True,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=True,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch(
- "steam_backlog_enforcer.scanning._echo",
- side_effect=lambda *a, **_: echoed.append(a[0]),
- ),
- patch(
- "steam_backlog_enforcer._scanning_confidence._echo",
- side_effect=lambda *a, **_: echoed.append(a[0]),
- ),
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=True,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.scanning._echo",
- side_effect=lambda *a, **_: echoed.append(a[0]),
- ),
- patch(
- "steam_backlog_enforcer._scanning_confidence._echo",
- side_effect=lambda *a, **_: echoed.append(a[0]),
- ),
- patch(
- "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)
diff --git a/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py b/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py
new file mode 100644
index 0000000..80321a5
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py
@@ -0,0 +1,198 @@
+"""Tests for scanning module (part 3): TestPickNextGame continued."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer.config import Config, State
+from steam_backlog_enforcer.scanning import pick_next_game
+from 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 TestPickNextGameGroup2:
+ """Tests for pick_next_game (continued from test_scanning.py)."""
+
+ 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(
+ "steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache",
+ return_value={1: 1, 2: 3},
+ ),
+ patch(
+ "steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache",
+ return_value={1: 8, 2: 20},
+ ),
+ patch(
+ "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch"
+ ) as mock_refresh_batch,
+ patch(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=True,
+ ),
+ patch(
+ "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:
+ """Test playable side effect."""
+ 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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=playable_side_effect,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=True,
+ ),
+ patch(
+ "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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=True,
+ ),
+ patch(
+ "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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning._echo",
+ side_effect=lambda *a, **_: echoed.append(a[0]),
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=True,
+ ),
+ patch(
+ "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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning._echo",
+ side_effect=lambda *a, **_: echoed.append(a[0]),
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=True,
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning.uninstall_other_games",
+ return_value=0,
+ ),
+ patch("builtins.input", side_effect=["99", "1"]),
+ ):
+ pick_next_game([g1], state, config)
+ assert state.current_app_id == 1
+ assert any("Out of range" in line for line in echoed)
diff --git a/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py
new file mode 100644
index 0000000..729b743
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py
@@ -0,0 +1,133 @@
+"""Scanning tests (part 4): collect_top_candidates, do_check, confidence."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer._scanning_confidence import (
+ _filter_hltb_confident_candidates,
+ _force_refresh_candidate_confidence,
+ _refresh_candidate_confidence_batch,
+)
+from steam_backlog_enforcer.scanning import (
+ _pick_next_shortest_candidate,
+)
+from 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 TestConfidenceHelpers:
+ """Coverage-focused tests for scanning confidence helper branches."""
+
+ def test_force_refresh_candidate_confidence_delegates(self) -> None:
+ """Test force refresh candidate confidence delegates."""
+ game = _game(app_id=10, name="A")
+ with patch(
+ "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:
+ """Test refresh candidate confidence batch no missing skips fetch."""
+ game = _game(app_id=20, name="B", hours=12.0)
+ game.comp_100_count = 3
+ game.count_comp = 15
+ with patch(
+ "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:
+ """Test refresh candidate confidence batch preserves existing hours."""
+ game = _game(app_id=30, name="C", hours=9.5)
+ game.comp_100_count = 0
+ game.count_comp = 0
+ with (
+ patch(
+ "steam_backlog_enforcer._scanning_confidence.load_hltb_cache",
+ side_effect=[{30: 9.5}, {30: -1.0}],
+ ),
+ patch(
+ "steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache",
+ return_value={30: 0},
+ ),
+ patch(
+ "steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache",
+ return_value={30: 0},
+ ),
+ patch(
+ "steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached",
+ return_value={30: -1.0},
+ ),
+ patch(
+ "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:
+ """Test filter hltb confident candidates skips low confidence."""
+ low = _game(app_id=40, name="Low", hours=2.0)
+ low.comp_100_count = 1
+ low.count_comp = 2
+ with (
+ patch(
+ "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch",
+ ),
+ patch("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:
+ """Test pick next shortest candidate logs skipped unplayable batches."""
+ 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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=[None, good],
+ ),
+ patch("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
+ )
diff --git a/steam_backlog_enforcer/tests/test_scanning_confidence_helpers_pick_next_shortest_candidate_no_echo_whe.py b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers_pick_next_shortest_candidate_no_echo_whe.py
new file mode 100644
index 0000000..d66fa57
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers_pick_next_shortest_candidate_no_echo_whe.py
@@ -0,0 +1,157 @@
+"""Scanning tests (part 4): collect_top_candidates, do_check, confidence."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+from steam_backlog_enforcer.config import Config, State
+from steam_backlog_enforcer.scanning import (
+ _pick_next_shortest_candidate,
+ do_check,
+)
+from 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 TestConfidenceHelpersGroup2:
+ """Coverage-focused tests for scanning confidence helper branches."""
+
+ 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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ return_value=good,
+ ),
+ patch("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(
+ "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence"
+ ),
+ patch("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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ return_value=None,
+ ),
+ patch("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(
+ "steam_backlog_enforcer.scanning.SteamAPIClient",
+ return_value=mock_client,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch(
+ "steam_backlog_enforcer.scanning.send_notification",
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning.load_snapshot",
+ return_value=snap,
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning.pick_next_game",
+ ),
+ patch("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:
+ """Test complete no snapshot."""
+ game = _game(app_id=440, name="TF2", total=5, unlocked=5)
+ mock_client = MagicMock()
+ mock_client.refresh_single_game.return_value = game
+ with (
+ patch(
+ "steam_backlog_enforcer.scanning.SteamAPIClient",
+ return_value=mock_client,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch(
+ "steam_backlog_enforcer.scanning.send_notification",
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning.load_snapshot",
+ return_value=None,
+ ),
+ patch("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:
+ """Test not complete."""
+ game = _game(app_id=440, name="TF2", total=10, unlocked=5)
+ mock_client = MagicMock()
+ mock_client.refresh_single_game.return_value = game
+ with (
+ patch(
+ "steam_backlog_enforcer.scanning.SteamAPIClient",
+ return_value=mock_client,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch("steam_backlog_enforcer.scanning.detect_tampering"),
+ ):
+ state = State(current_app_id=440, current_game_name="TF2")
+ do_check(Config(steam_api_key="k", steam_id="i"), state)
diff --git a/steam_backlog_enforcer/tests/test_scanning_part3.py b/steam_backlog_enforcer/tests/test_scanning_part3.py
index 29ef412..030a786 100644
--- a/steam_backlog_enforcer/tests/test_scanning_part3.py
+++ b/steam_backlog_enforcer/tests/test_scanning_part3.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import contextlib
from unittest.mock import patch
from steam_backlog_enforcer.config import Config, State
@@ -44,6 +43,7 @@ class TestPickNextGame:
echoed: list[str] = []
def refresh_side_effect(game: GameInfo) -> None:
+ """Test refresh side effect."""
if game.app_id == 1:
game.comp_100_count = 899
game.count_comp = 14055
@@ -114,299 +114,3 @@ class TestPickNextGame:
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(
- "steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache",
- return_value={1: 1, 2: 3},
- ),
- patch(
- "steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache",
- return_value={1: 8, 2: 20},
- ),
- patch(
- "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch"
- ) as mock_refresh_batch,
- patch(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=True,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=playable_side_effect,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=True,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=True,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch(
- "steam_backlog_enforcer.scanning._echo",
- side_effect=lambda *a, **_: echoed.append(a[0]),
- ),
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=True,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch(
- "steam_backlog_enforcer.scanning._echo",
- side_effect=lambda *a, **_: echoed.append(a[0]),
- ),
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=True,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- )
- )
- stack.enter_context(
- patch(
- "steam_backlog_enforcer.scanning._echo",
- side_effect=lambda *a, **_: echoed.append(a[0]),
- )
- )
- stack.enter_context(
- patch(
- "steam_backlog_enforcer.scanning.is_game_installed",
- return_value=True,
- )
- )
- stack.enter_context(
- patch(
- "steam_backlog_enforcer.scanning.uninstall_other_games",
- return_value=0,
- )
- )
- stack.enter_context(patch("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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- return_value=None,
- ),
- patch(
- "steam_backlog_enforcer.scanning._echo",
- side_effect=lambda *a, **_: echoed.append(a[0]),
- ),
- patch("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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=lambda c: c[0] if c else None,
- ),
- patch(
- "steam_backlog_enforcer.scanning._echo",
- side_effect=lambda *a, **_: echoed.append(a[0]),
- ),
- patch(
- "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence",
- ),
- patch("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
diff --git a/steam_backlog_enforcer/tests/test_scanning_part4.py b/steam_backlog_enforcer/tests/test_scanning_part4.py
index 479130a..8f62609 100644
--- a/steam_backlog_enforcer/tests/test_scanning_part4.py
+++ b/steam_backlog_enforcer/tests/test_scanning_part4.py
@@ -4,15 +4,9 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch
-from steam_backlog_enforcer._scanning_confidence import (
- _filter_hltb_confident_candidates,
- _force_refresh_candidate_confidence,
- _refresh_candidate_confidence_batch,
-)
from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.scanning import (
_collect_top_candidates,
- _pick_next_shortest_candidate,
do_check,
)
from steam_backlog_enforcer.steam_api import GameInfo
@@ -70,6 +64,7 @@ class TestCollectTopCandidates:
assert conf_skip == 0
def test_empty_candidates(self) -> None:
+ """Test empty candidates."""
qualified, conf_skip, linux_skip = _collect_top_candidates([])
assert qualified == []
assert conf_skip == 0
@@ -93,11 +88,13 @@ class TestDoCheck:
"""Tests for do_check."""
def test_no_assignment(self) -> None:
+ """Test no assignment."""
with patch("steam_backlog_enforcer.scanning._echo") as mock_echo:
do_check(Config(), State())
mock_echo.assert_called()
def test_fetch_fails(self) -> None:
+ """Test fetch fails."""
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = None
with (
@@ -110,217 +107,3 @@ class TestDoCheck:
):
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(
- "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(
- "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(
- "steam_backlog_enforcer._scanning_confidence.load_hltb_cache",
- side_effect=[{30: 9.5}, {30: -1.0}],
- ),
- patch(
- "steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache",
- return_value={30: 0},
- ),
- patch(
- "steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache",
- return_value={30: 0},
- ),
- patch(
- "steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached",
- return_value={30: -1.0},
- ),
- patch(
- "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(
- "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch",
- ),
- patch("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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- side_effect=[None, good],
- ),
- patch("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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- return_value=good,
- ),
- patch("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(
- "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence"
- ),
- patch("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(
- "steam_backlog_enforcer.scanning._pick_playable_candidate",
- return_value=None,
- ),
- patch("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(
- "steam_backlog_enforcer.scanning.SteamAPIClient",
- return_value=mock_client,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch(
- "steam_backlog_enforcer.scanning.send_notification",
- ),
- patch(
- "steam_backlog_enforcer.scanning.load_snapshot",
- return_value=snap,
- ),
- patch(
- "steam_backlog_enforcer.scanning.pick_next_game",
- ),
- patch("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(
- "steam_backlog_enforcer.scanning.SteamAPIClient",
- return_value=mock_client,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch(
- "steam_backlog_enforcer.scanning.send_notification",
- ),
- patch(
- "steam_backlog_enforcer.scanning.load_snapshot",
- return_value=None,
- ),
- patch("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(
- "steam_backlog_enforcer.scanning.SteamAPIClient",
- return_value=mock_client,
- ),
- patch("steam_backlog_enforcer.scanning._echo"),
- patch("steam_backlog_enforcer.scanning.detect_tampering"),
- ):
- state = State(current_app_id=440, current_game_name="TF2")
- do_check(Config(steam_api_key="k", steam_id="i"), state)
diff --git a/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py b/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py
new file mode 100644
index 0000000..16c354c
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py
@@ -0,0 +1,156 @@
+"""Tests for scanning module."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer.config import Config, State
+from steam_backlog_enforcer.scanning import (
+ pick_next_game,
+)
+from 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."""
+
+ def test_picks_shortest(self) -> None:
+ """Test picks shortest."""
+ 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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch("steam_backlog_enforcer._scanning_confidence._echo"),
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=True,
+ ),
+ patch(
+ "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:
+ """Test no candidates."""
+ g1 = _game(app_id=1, total=5, unlocked=5)
+ config = Config(steam_api_key="k", steam_id="i")
+ state = State()
+ with patch("steam_backlog_enforcer.scanning._echo"):
+ pick_next_game([g1], state, config)
+ assert state.current_app_id is None
+
+ def test_skips_finished(self) -> None:
+ """Test skips finished."""
+ 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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=True,
+ ),
+ patch(
+ "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:
+ """Test no playable."""
+ g1 = _game(app_id=1, name="G1")
+ config = Config(steam_api_key="k", steam_id="i")
+ state = State()
+ with (
+ patch(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ return_value=None,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ ):
+ pick_next_game([g1], state, config)
+ assert state.current_app_id is None
+
+ def test_uninstalls_others(self) -> None:
+ """Test uninstalls others."""
+ 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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch("steam_backlog_enforcer._scanning_confidence._echo"),
+ patch(
+ "steam_backlog_enforcer.scanning.uninstall_other_games",
+ return_value=2,
+ ),
+ patch(
+ "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:
+ """Test auto installs."""
+ 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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch("steam_backlog_enforcer._scanning_confidence._echo"),
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=False,
+ ),
+ patch("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()
diff --git a/steam_backlog_enforcer/tests/test_scanning_pick_next_game_sequential.py b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_sequential.py
new file mode 100644
index 0000000..b893a0b
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_sequential.py
@@ -0,0 +1,164 @@
+"""Tests for scanning module (part 3): TestPickNextGame continued."""
+
+from __future__ import annotations
+
+import contextlib
+from unittest.mock import patch
+
+from steam_backlog_enforcer.config import Config, State
+from steam_backlog_enforcer.scanning import pick_next_game
+from 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 TestPickNextGameSequential:
+ """Tests for the on_select sequential branch of pick_next_game."""
+
+ @staticmethod
+ def _common_patches(echoed: list[str]) -> contextlib.ExitStack:
+ """Test common patches."""
+ stack = contextlib.ExitStack()
+ stack.enter_context(
+ patch(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ )
+ )
+ stack.enter_context(
+ patch(
+ "steam_backlog_enforcer.scanning._echo",
+ side_effect=lambda *a, **_: echoed.append(a[0]),
+ )
+ )
+ stack.enter_context(
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=True,
+ )
+ )
+ stack.enter_context(
+ patch(
+ "steam_backlog_enforcer.scanning.uninstall_other_games",
+ return_value=0,
+ )
+ )
+ stack.enter_context(patch("steam_backlog_enforcer.config._atomic_write"))
+ return stack
+
+ def test_on_select_accepts_pick(self) -> None:
+ """Test on select accepts pick."""
+ 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:
+ """Test on select rejection records skip and picks next."""
+ 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:
+ """Test on select."""
+ 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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ return_value=None,
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning._echo",
+ side_effect=lambda *a, **_: echoed.append(a[0]),
+ ),
+ patch("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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning._echo",
+ side_effect=lambda *a, **_: echoed.append(a[0]),
+ ),
+ patch(
+ "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence",
+ ),
+ patch("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
diff --git a/steam_backlog_enforcer/tests/test_scanning_pick_next_game_unknown_hours.py b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_unknown_hours.py
new file mode 100644
index 0000000..2d90c1f
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_unknown_hours.py
@@ -0,0 +1,152 @@
+"""Tests for scanning module."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer.config import Config, State
+from steam_backlog_enforcer.scanning import (
+ pick_next_game,
+)
+from 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 TestPickNextGameGroup2:
+ """Tests for pick_next_game."""
+
+ def test_unknown_hours(self) -> None:
+ """Test unknown hours."""
+ 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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=True,
+ ),
+ patch(
+ "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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch("steam_backlog_enforcer.scanning._echo"),
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=True,
+ ),
+ patch(
+ "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:
+ """Test skips low confidence and picks next."""
+ 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(
+ "steam_backlog_enforcer.scanning._pick_playable_candidate",
+ side_effect=lambda c: c[0] if c else None,
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning._echo",
+ side_effect=lambda *a, **_: echoed.append(a[0]),
+ ),
+ patch(
+ "steam_backlog_enforcer._scanning_confidence._echo",
+ side_effect=lambda *a, **_: echoed.append(a[0]),
+ ),
+ patch(
+ "steam_backlog_enforcer.scanning.is_game_installed",
+ return_value=True,
+ ),
+ patch(
+ "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:
+ """Test all candidates filtered by confidence."""
+ 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(
+ "steam_backlog_enforcer.scanning._echo",
+ side_effect=lambda *a, **_: echoed.append(a[0]),
+ ),
+ patch(
+ "steam_backlog_enforcer._scanning_confidence._echo",
+ side_effect=lambda *a, **_: echoed.append(a[0]),
+ ),
+ patch(
+ "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)
diff --git a/steam_backlog_enforcer/tests/test_stats.py b/steam_backlog_enforcer/tests/test_stats.py
index 5211e8a..baa3066 100644
--- a/steam_backlog_enforcer/tests/test_stats.py
+++ b/steam_backlog_enforcer/tests/test_stats.py
@@ -2,26 +2,15 @@
from __future__ import annotations
-from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from steam_backlog_enforcer._stats import (
- _ensure_completed_rush_data,
- _ensure_rush_data,
_filter_qualifying_games,
- _format_completion_date,
_GameTimes,
- _print_pace_scenario,
- _print_player_speed_scenario,
- _print_scenario,
- _print_worst_example,
- _sum_hours,
- cmd_stats,
)
-from steam_backlog_enforcer._web_dataset import PaceVsHLTB
-from steam_backlog_enforcer.config import Config, State
+from steam_backlog_enforcer.config import State
from steam_backlog_enforcer.protondb import ProtonDBRating
-from steam_backlog_enforcer.steam_api import GameInfo, SteamAPIError
+from steam_backlog_enforcer.steam_api import GameInfo
_PKG = "steam_backlog_enforcer._stats"
@@ -60,6 +49,7 @@ class TestFilterQualifyingGames:
leisure_cache: dict[int, float] | None = None,
game_id_cache: dict[int, int] | None = None,
) -> tuple[list[_GameTimes], int, int, int]:
+ """Test run."""
with (
patch(f"{_PKG}.load_hltb_rush_cache", return_value=rush_cache or {}),
patch(
@@ -78,6 +68,7 @@ class TestFilterQualifyingGames:
return _filter_qualifying_games(games, state)
def test_current_app_id_excluded(self) -> None:
+ """Test current app id excluded."""
state = State(current_app_id=1)
g1 = _game(app_id=1)
g2 = _game(app_id=2)
@@ -94,6 +85,7 @@ class TestFilterQualifyingGames:
assert len(qualified) == 1
def test_finished_app_ids_excluded(self) -> None:
+ """Test finished app ids excluded."""
state = State()
state.finished_app_ids = [1]
g1 = _game(app_id=1)
@@ -111,6 +103,7 @@ class TestFilterQualifyingGames:
assert qualified[0].game.app_id == 2
def test_low_confidence_counts_hltb_skipped(self) -> None:
+ """Test low confidence counts hltb skipped."""
state = State()
g = _game(app_id=1)
with (
@@ -141,932 +134,3 @@ class TestFilterQualifyingGames:
):
_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}._ensure_completed_rush_data", return_value=False),
- patch(f"{_PKG}._print_player_speed_scenario"),
- 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}._ensure_completed_rush_data", return_value=False),
- patch(f"{_PKG}._print_player_speed_scenario"),
- 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_completed_rush_data", return_value=False),
- patch(f"{_PKG}._ensure_rush_data", return_value=False),
- patch(f"{_PKG}._print_player_speed_scenario"),
- 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_completed_rush_data", return_value=False),
- patch(f"{_PKG}._ensure_rush_data", return_value=False),
- patch(f"{_PKG}._print_player_speed_scenario"),
- 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_completed_rush_data", return_value=False),
- patch(f"{_PKG}._ensure_rush_data", return_value=True),
- patch(f"{_PKG}._print_player_speed_scenario"),
- 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_since_start_passed_to_pace(self) -> None:
- """_print_pace_scenario gets only games completed after started_at."""
- from datetime import datetime, timezone
-
- started = datetime(2026, 1, 1, tzinfo=timezone.utc)
- state = State(enforcement_started_at=started.isoformat())
-
- after_ts = int(datetime(2026, 3, 1, tzinfo=timezone.utc).timestamp())
- before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp())
-
- def _ach(ts: int) -> dict[str, object]:
- return {
- "api_name": "A",
- "display_name": "A",
- "achieved": True,
- "unlock_time": ts,
- }
-
- # app_id=2: completed AFTER enforcement start → should count
- snapshot_after = {
- **self._snapshot_game(app_id=2),
- "unlocked_achievements": 10,
- "achievements": [_ach(after_ts)] * 10,
- }
- # app_id=3: completed BEFORE enforcement start → should NOT count
- snapshot_before = {
- **self._snapshot_game(app_id=3),
- "unlocked_achievements": 10,
- "achievements": [_ach(before_ts)] * 10,
- }
- snapshot = [self._snapshot_game(app_id=1), snapshot_after, snapshot_before]
- 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}._ensure_completed_rush_data", return_value=False),
- patch(f"{_PKG}._print_player_speed_scenario"),
- 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 # only the post-start game
-
- def test_player_speed_scenario_called_with_pace_and_totals(self) -> None:
- """_print_player_speed_scenario receives pace, rush_total, and leisure_total."""
- 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
- )
- pace = PaceVsHLTB(
- calibration_count=5,
- ratio_vs_rush=1.1,
- ratio_vs_leisure=0.4,
- interpolation_t=0.05,
- player_style="rush_to_leisure",
- )
- captured: dict[str, object] = {}
-
- def capture_player_speed(p: object, rush: float, leisure: float) -> None:
- captured["pace"] = p
- captured["rush"] = rush
- captured["leisure"] = leisure
-
- 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_completed_rush_data", return_value=False),
- patch(f"{_PKG}.compute_pace_vs_hltb", return_value=pace),
- patch(
- f"{_PKG}._print_player_speed_scenario",
- side_effect=capture_player_speed,
- ),
- 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 captured["pace"] is pace
- assert captured["rush"] == 15.0
- assert captured["leisure"] == 25.0
-
-
-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 TestEnsureCompletedRushData:
- """Tests for _ensure_completed_rush_data."""
-
- def _complete(self, app_id: int = 1, playtime: int = 600) -> GameInfo:
- return GameInfo(
- app_id=app_id,
- name="Done",
- total_achievements=10,
- unlocked_achievements=10,
- playtime_minutes=playtime,
- completionist_hours=0.0,
- comp_100_count=5,
- count_comp=20,
- )
-
- def test_no_complete_games_returns_false_without_fetch(self) -> None:
- incomplete = _game(app_id=1, total=10, unlocked=0)
- with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch:
- result = _ensure_completed_rush_data([incomplete])
- assert result is False
- mock_fetch.assert_not_called()
-
- def test_complete_game_with_zero_playtime_excluded(self) -> None:
- """Games with playtime_minutes=0 are skipped (no calibration value)."""
- no_play = self._complete(playtime=0)
- with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch:
- result = _ensure_completed_rush_data([no_play])
- assert result is False
- mock_fetch.assert_not_called()
-
- def test_complete_game_with_playtime_fetches(self) -> None:
- game = self._complete()
- with (
- patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=1) as mock_fetch,
- patch(f"{_PKG}._echo"),
- ):
- result = _ensure_completed_rush_data([game])
- assert result is True
- mock_fetch.assert_called_once_with([(1, "Done")])
-
- def test_fetch_returns_zero_means_no_new_data(self) -> None:
- """When fetch_hltb_detail_missing returns 0, return False (all cached)."""
- game = self._complete()
- with (
- patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0),
- patch(f"{_PKG}._echo"),
- ):
- result = _ensure_completed_rush_data([game])
- assert result is False
-
-
-class TestPrintPlayerSpeedScenario:
- """Tests for _print_player_speed_scenario — 100 % branch coverage."""
-
- def _echoed(
- self,
- pace: PaceVsHLTB | None,
- rush: float = 100.0,
- leisure: float = 200.0,
- ) -> list[str]:
- out: list[str] = []
- with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: out.append(a[0])):
- _print_player_speed_scenario(pace, rush, leisure)
- return out
-
- def test_none_pace_shows_no_calibration_message(self) -> None:
- echoed = self._echoed(None)
- assert any("No calibration data" in s for s in echoed)
-
- def test_zero_calibration_count_shows_no_calibration_message(self) -> None:
- pace = PaceVsHLTB(
- calibration_count=0,
- ratio_vs_rush=-1.0,
- ratio_vs_leisure=-1.0,
- interpolation_t=-1.0,
- player_style="unknown",
- )
- echoed = self._echoed(pace)
- assert any("No calibration data" in s for s in echoed)
-
- def test_ratio_vs_rush_shown_when_positive(self) -> None:
- pace = PaceVsHLTB(
- calibration_count=5,
- ratio_vs_rush=1.05,
- ratio_vs_leisure=-1.0,
- interpolation_t=-1.0,
- player_style="unknown",
- )
- echoed = self._echoed(pace)
- assert any("rush pace" in s for s in echoed)
-
- def test_ratio_vs_leisure_shown_when_positive(self) -> None:
- pace = PaceVsHLTB(
- calibration_count=5,
- ratio_vs_rush=1.05,
- ratio_vs_leisure=0.5,
- interpolation_t=-1.0,
- player_style="unknown",
- )
- echoed = self._echoed(pace)
- assert any("leisure pace" in s for s in echoed)
-
- def test_interpolation_t_shown_when_not_minus_one(self) -> None:
- pace = PaceVsHLTB(
- calibration_count=5,
- ratio_vs_rush=1.05,
- ratio_vs_leisure=0.5,
- interpolation_t=0.1,
- player_style="rush_to_leisure",
- )
- echoed = self._echoed(pace)
- assert any("Interpolation t" in s for s in echoed)
-
- def test_estimate_uses_interpolation_when_available(self) -> None:
- # rush=100, leisure=200, t=0.5 → est=150
- pace = PaceVsHLTB(
- calibration_count=5,
- ratio_vs_rush=1.5,
- ratio_vs_leisure=0.5,
- interpolation_t=0.5,
- player_style="rush_to_leisure",
- )
- echoed = self._echoed(pace, rush=100.0, leisure=200.0)
- assert any("150" in s for s in echoed)
-
- def test_estimate_falls_back_to_ratio_when_no_interpolation(self) -> None:
- # interpolation_t=-1, ratio_vs_rush=2.0, rush=100 → est=200
- pace = PaceVsHLTB(
- calibration_count=5,
- ratio_vs_rush=2.0,
- ratio_vs_leisure=-1.0,
- interpolation_t=-1.0,
- player_style="unknown",
- )
- echoed = self._echoed(pace, rush=100.0, leisure=0.0)
- assert any("200" in s for s in echoed)
-
- def test_no_estimate_when_both_methods_unavailable(self) -> None:
- """No 'Estimated backlog total' line when t=-1 and ratio=-1."""
- pace = PaceVsHLTB(
- calibration_count=5,
- ratio_vs_rush=-1.0,
- ratio_vs_leisure=-1.0,
- interpolation_t=-1.0,
- player_style="unknown",
- )
- echoed = self._echoed(pace, rush=100.0, leisure=0.0)
- assert not any("Estimated backlog total" in s for s in echoed)
-
- def test_no_estimate_when_rush_total_zero_and_no_interpolation(self) -> None:
- """No estimate line when rush_total=0 and interpolation_t=-1."""
- pace = PaceVsHLTB(
- calibration_count=5,
- ratio_vs_rush=1.5,
- ratio_vs_leisure=-1.0,
- interpolation_t=-1.0,
- player_style="unknown",
- )
- echoed = self._echoed(pace, rush=0.0, leisure=0.0)
- assert not any("Estimated backlog total" in s for s in echoed)
-
-
-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,
- hltb_game_id=99999,
- )
- 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,
- hltb_game_id=99999,
- )
- 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,
- hltb_game_id=99999,
- )
- 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_lookup_finds_nothing(self) -> None:
- """Falls back to search URL when hltb_game_id is 0 and lookup finds nothing."""
- 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])),
- patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0),
- patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}),
- ):
- _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_after_on_demand_lookup(self) -> None:
- """Direct link shown when on-demand lookup successfully finds the game ID."""
- entry = _GameTimes(
- game=_game(app_id=111, 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])),
- patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0),
- patch(f"{_PKG}.load_hltb_game_id_cache", return_value={111: 42000}),
- ):
- _print_worst_example([entry])
- assert any("howlongtobeat.com/game/42000" in s for s in echoed)
- assert not any("?q=" 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,
- hltb_game_id=99999,
- )
- 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)
-
-
-class TestRefreshRecentlyPlayedCompletions:
- """Tests for _refresh_recently_played_completions."""
-
- def test_oserror_on_stat_returns_games_unchanged(self) -> None:
- games = [GameInfo(1, "G", 10, 0, 60)]
- with patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf:
- mock_sf.stat.side_effect = OSError("no file")
- from steam_backlog_enforcer._stats import (
- _refresh_recently_played_completions,
- )
-
- result = _refresh_recently_played_completions(games, Config())
- assert result == games
-
- def test_steam_api_error_returns_games_unchanged(self) -> None:
- """A SteamAPIError while fetching owned games is swallowed."""
- games = [GameInfo(1, "G", 10, 0, 60)]
- with (
- patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf,
- patch(f"{_PKG}.SteamAPIClient") as mock_cls,
- ):
- mock_sf.stat.return_value.st_mtime = 1_000_000.0
- mock_cls.return_value.get_owned_games.side_effect = SteamAPIError(
- "api down"
- )
- from steam_backlog_enforcer._stats import (
- _refresh_recently_played_completions,
- )
-
- result = _refresh_recently_played_completions(games, Config())
- assert result == games
-
- def test_no_recently_played_returns_games_unchanged(self) -> None:
- games = [GameInfo(1, "G", 10, 0, 60)]
- with (
- patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf,
- patch(f"{_PKG}.SteamAPIClient") as mock_cls,
- ):
- mock_sf.stat.return_value.st_mtime = 1_000_000.0
- mock_cls.return_value.get_owned_games.return_value = [
- {"appid": 1, "rtime_last_played": 500_000}
- ]
- from steam_backlog_enforcer._stats import (
- _refresh_recently_played_completions,
- )
-
- result = _refresh_recently_played_completions(games, Config())
- assert result == games
-
- def test_recently_played_game_is_refreshed(self) -> None:
- from steam_backlog_enforcer._stats import _refresh_recently_played_completions
- from steam_backlog_enforcer.steam_api import AchievementInfo
-
- game = GameInfo(1, "G", 5, 0, 60)
- new_achievements = [
- AchievementInfo("a1", "A1", achieved=True, unlock_time=1_500_001),
- AchievementInfo("a2", "A2", achieved=True, unlock_time=1_500_002),
- AchievementInfo("a3", "A3", achieved=False, unlock_time=0),
- AchievementInfo("a4", "A4", achieved=False, unlock_time=0),
- AchievementInfo("a5", "A5", achieved=False, unlock_time=0),
- ]
- with (
- patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf,
- patch(f"{_PKG}.SteamAPIClient") as mock_cls,
- patch(f"{_PKG}._echo"),
- ):
- mock_sf.stat.return_value.st_mtime = 1_000_000.0
- mock_cls.return_value.get_owned_games.return_value = [
- {"appid": 1, "rtime_last_played": 1_500_000}
- ]
- mock_cls.return_value.get_achievement_details.return_value = (
- new_achievements
- )
- result = _refresh_recently_played_completions([game], Config())
- refreshed = next(g for g in result if g.app_id == 1)
- assert refreshed.unlocked_achievements == 2
-
- def test_get_achievement_details_empty_keeps_old_game(self) -> None:
- from steam_backlog_enforcer._stats import _refresh_recently_played_completions
-
- game = GameInfo(1, "G", 5, 3, 60)
- with (
- patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf,
- patch(f"{_PKG}.SteamAPIClient") as mock_cls,
- patch(f"{_PKG}._echo"),
- ):
- mock_sf.stat.return_value.st_mtime = 1_000_000.0
- mock_cls.return_value.get_owned_games.return_value = [
- {"appid": 1, "rtime_last_played": 1_500_000}
- ]
- mock_cls.return_value.get_achievement_details.return_value = []
- result = _refresh_recently_played_completions([game], Config())
- refreshed = next(g for g in result if g.app_id == 1)
- assert refreshed.unlocked_achievements == 3
diff --git a/steam_backlog_enforcer/tests/test_stats_cmd_stats.py b/steam_backlog_enforcer/tests/test_stats_cmd_stats.py
new file mode 100644
index 0000000..3833992
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_stats_cmd_stats.py
@@ -0,0 +1,224 @@
+"""Tests for _stats module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer._stats import (
+ _GameTimes,
+ cmd_stats,
+)
+from steam_backlog_enforcer.config import Config, State
+from steam_backlog_enforcer.protondb import ProtonDBRating
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_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 TestCmdStats:
+ """Tests for cmd_stats."""
+
+ def _config(self) -> Config:
+ """Test config."""
+ return Config(steam_api_key="k", steam_id="i")
+
+ def _snapshot_game(self, app_id: int = 1, hours: float = 20.0) -> dict[str, object]:
+ """Test snapshot game."""
+ 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]:
+ """Test run cmd stats."""
+ 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}._ensure_completed_rush_data", return_value=False),
+ patch(f"{_PKG}._print_player_speed_scenario"),
+ 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_no_snapshot(self) -> None:
+ """Test no snapshot."""
+ 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 test_with_no_current_game(self) -> None:
+ """Test with no current game."""
+ 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:
+ """Test with current game."""
+ 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:
+ """Test hltb skipped shown."""
+ 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:
+ """Test linux skipped shown."""
+ 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:
+ """Test no data skipped shown."""
+ 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:
+ """Test zero skips not shown."""
+ 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}._ensure_completed_rush_data", return_value=False),
+ patch(f"{_PKG}._print_player_speed_scenario"),
+ 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_completed_rush_data", return_value=False),
+ patch(f"{_PKG}._ensure_rush_data", return_value=False),
+ patch(f"{_PKG}._print_player_speed_scenario"),
+ 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_completed_rush_data", return_value=False),
+ patch(f"{_PKG}._ensure_rush_data", return_value=False),
+ patch(f"{_PKG}._print_player_speed_scenario"),
+ 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)
diff --git a/steam_backlog_enforcer/tests/test_stats_cmd_stats_refilter_called_when_ensure_rush_data_re.py b/steam_backlog_enforcer/tests/test_stats_cmd_stats_refilter_called_when_ensure_rush_data_re.py
new file mode 100644
index 0000000..d7487c7
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_stats_cmd_stats_refilter_called_when_ensure_rush_data_re.py
@@ -0,0 +1,223 @@
+"""Tests for _stats module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from unittest.mock import patch
+
+from steam_backlog_enforcer._stats import (
+ _GameTimes,
+ cmd_stats,
+)
+from steam_backlog_enforcer._web_dataset import PaceVsHLTB
+from steam_backlog_enforcer.config import Config, State
+from steam_backlog_enforcer.protondb import ProtonDBRating
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_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 TestCmdStatsGroup2:
+ """Tests for cmd_stats."""
+
+ def _config(self) -> Config:
+ """Test config."""
+ return Config(steam_api_key="k", steam_id="i")
+
+ def _snapshot_game(self, app_id: int = 1, hours: float = 20.0) -> dict[str, object]:
+ """Test snapshot game."""
+ 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]:
+ """Test run cmd stats."""
+ 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}._ensure_completed_rush_data", return_value=False),
+ patch(f"{_PKG}._print_player_speed_scenario"),
+ 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_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]:
+ """Test count filter."""
+ 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_completed_rush_data", return_value=False),
+ patch(f"{_PKG}._ensure_rush_data", return_value=True),
+ patch(f"{_PKG}._print_player_speed_scenario"),
+ 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_since_start_passed_to_pace(self) -> None:
+ """_print_pace_scenario gets only games completed after started_at."""
+
+ started = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ state = State(enforcement_started_at=started.isoformat())
+
+ after_ts = int(datetime(2026, 3, 1, tzinfo=timezone.utc).timestamp())
+ before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp())
+
+ def _ach(ts: int) -> dict[str, object]:
+ """Test ach."""
+ return {
+ "api_name": "A",
+ "display_name": "A",
+ "achieved": True,
+ "unlock_time": ts,
+ }
+
+ # app_id=2: completed AFTER enforcement start → should count
+ snapshot_after = {
+ **self._snapshot_game(app_id=2),
+ "unlocked_achievements": 10,
+ "achievements": [_ach(after_ts)] * 10,
+ }
+ # app_id=3: completed BEFORE enforcement start → should NOT count
+ snapshot_before = {
+ **self._snapshot_game(app_id=3),
+ "unlocked_achievements": 10,
+ "achievements": [_ach(before_ts)] * 10,
+ }
+ snapshot = [self._snapshot_game(app_id=1), snapshot_after, snapshot_before]
+ 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:
+ """Test capture pace."""
+ 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}._ensure_completed_rush_data", return_value=False),
+ patch(f"{_PKG}._print_player_speed_scenario"),
+ 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 # only the post-start game
+
+ def test_player_speed_scenario_called_with_pace_and_totals(self) -> None:
+ """_print_player_speed_scenario receives pace, rush_total, and leisure_total."""
+ 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
+ )
+ pace = PaceVsHLTB(
+ calibration_count=5,
+ ratio_vs_rush=1.1,
+ ratio_vs_leisure=0.4,
+ interpolation_t=0.05,
+ player_style="rush_to_leisure",
+ )
+ captured: dict[str, object] = {}
+
+ def capture_player_speed(p: object, rush: float, leisure: float) -> None:
+ """Test capture player speed."""
+ captured["pace"] = p
+ captured["rush"] = rush
+ captured["leisure"] = leisure
+
+ 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_completed_rush_data", return_value=False),
+ patch(f"{_PKG}.compute_pace_vs_hltb", return_value=pace),
+ patch(
+ f"{_PKG}._print_player_speed_scenario",
+ side_effect=capture_player_speed,
+ ),
+ 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 captured["pace"] is pace
+ assert captured["rush"] == 15.0
+ assert captured["leisure"] == 25.0
diff --git a/steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py b/steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py
new file mode 100644
index 0000000..02576d9
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py
@@ -0,0 +1,128 @@
+"""Tests for _stats module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer._stats import (
+ _ensure_completed_rush_data,
+ _ensure_rush_data,
+ _GameTimes,
+)
+from steam_backlog_enforcer.protondb import ProtonDBRating
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_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 TestEnsureRushData:
+ """Tests for _ensure_rush_data."""
+
+ def _entry(self, rush: float) -> _GameTimes:
+ """Test entry."""
+ return _GameTimes(
+ game=_game(), worst_hours=10.0, rush_hours=rush, leisure_100h=5.0
+ )
+
+ def test_empty_qualified_returns_false(self) -> None:
+ """Test empty qualified returns false."""
+ 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:
+ """Test all have rush returns false."""
+ 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:
+ """Test missing rush fetches and returns true."""
+ 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 TestEnsureCompletedRushData:
+ """Tests for _ensure_completed_rush_data."""
+
+ def _complete(self, app_id: int = 1, playtime: int = 600) -> GameInfo:
+ """Test complete."""
+ return GameInfo(
+ app_id=app_id,
+ name="Done",
+ total_achievements=10,
+ unlocked_achievements=10,
+ playtime_minutes=playtime,
+ completionist_hours=0.0,
+ comp_100_count=5,
+ count_comp=20,
+ )
+
+ def test_no_complete_games_returns_false_without_fetch(self) -> None:
+ """Test no complete games returns false without fetch."""
+ incomplete = _game(app_id=1, total=10, unlocked=0)
+ with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch:
+ result = _ensure_completed_rush_data([incomplete])
+ assert result is False
+ mock_fetch.assert_not_called()
+
+ def test_complete_game_with_zero_playtime_excluded(self) -> None:
+ """Games with playtime_minutes=0 are skipped (no calibration value)."""
+ no_play = self._complete(playtime=0)
+ with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch:
+ result = _ensure_completed_rush_data([no_play])
+ assert result is False
+ mock_fetch.assert_not_called()
+
+ def test_complete_game_with_playtime_fetches(self) -> None:
+ """Test complete game with playtime fetches."""
+ game = self._complete()
+ with (
+ patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=1) as mock_fetch,
+ patch(f"{_PKG}._echo"),
+ ):
+ result = _ensure_completed_rush_data([game])
+ assert result is True
+ mock_fetch.assert_called_once_with([(1, "Done")])
+
+ def test_fetch_returns_zero_means_no_new_data(self) -> None:
+ """When fetch_hltb_detail_missing returns 0, return False (all cached)."""
+ game = self._complete()
+ with (
+ patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0),
+ patch(f"{_PKG}._echo"),
+ ):
+ result = _ensure_completed_rush_data([game])
+ assert result is False
diff --git a/steam_backlog_enforcer/tests/test_stats_print_player_speed_scenario.py b/steam_backlog_enforcer/tests/test_stats_print_player_speed_scenario.py
new file mode 100644
index 0000000..a999dc7
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_stats_print_player_speed_scenario.py
@@ -0,0 +1,156 @@
+"""Tests for _stats module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer._stats import (
+ _print_player_speed_scenario,
+)
+from steam_backlog_enforcer._web_dataset import PaceVsHLTB
+from steam_backlog_enforcer.protondb import ProtonDBRating
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_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 TestPrintPlayerSpeedScenario:
+ """Tests for _print_player_speed_scenario — 100 % branch coverage."""
+
+ def _echoed(
+ self,
+ pace: PaceVsHLTB | None,
+ rush: float = 100.0,
+ leisure: float = 200.0,
+ ) -> list[str]:
+ """Test echoed."""
+ out: list[str] = []
+ with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: out.append(a[0])):
+ _print_player_speed_scenario(pace, rush, leisure)
+ return out
+
+ def test_none_pace_shows_no_calibration_message(self) -> None:
+ """Test none pace shows no calibration message."""
+ echoed = self._echoed(None)
+ assert any("No calibration data" in s for s in echoed)
+
+ def test_zero_calibration_count_shows_no_calibration_message(self) -> None:
+ """Test zero calibration count shows no calibration message."""
+ pace = PaceVsHLTB(
+ calibration_count=0,
+ ratio_vs_rush=-1.0,
+ ratio_vs_leisure=-1.0,
+ interpolation_t=-1.0,
+ player_style="unknown",
+ )
+ echoed = self._echoed(pace)
+ assert any("No calibration data" in s for s in echoed)
+
+ def test_ratio_vs_rush_shown_when_positive(self) -> None:
+ """Test ratio vs rush shown when positive."""
+ pace = PaceVsHLTB(
+ calibration_count=5,
+ ratio_vs_rush=1.05,
+ ratio_vs_leisure=-1.0,
+ interpolation_t=-1.0,
+ player_style="unknown",
+ )
+ echoed = self._echoed(pace)
+ assert any("rush pace" in s for s in echoed)
+
+ def test_ratio_vs_leisure_shown_when_positive(self) -> None:
+ """Test ratio vs leisure shown when positive."""
+ pace = PaceVsHLTB(
+ calibration_count=5,
+ ratio_vs_rush=1.05,
+ ratio_vs_leisure=0.5,
+ interpolation_t=-1.0,
+ player_style="unknown",
+ )
+ echoed = self._echoed(pace)
+ assert any("leisure pace" in s for s in echoed)
+
+ def test_interpolation_t_shown_when_not_minus_one(self) -> None:
+ """Test interpolation t shown when not minus one."""
+ pace = PaceVsHLTB(
+ calibration_count=5,
+ ratio_vs_rush=1.05,
+ ratio_vs_leisure=0.5,
+ interpolation_t=0.1,
+ player_style="rush_to_leisure",
+ )
+ echoed = self._echoed(pace)
+ assert any("Interpolation t" in s for s in echoed)
+
+ def test_estimate_uses_interpolation_when_available(self) -> None:
+ # rush=100, leisure=200, t=0.5 → est=150
+ """Test estimate uses interpolation when available."""
+ pace = PaceVsHLTB(
+ calibration_count=5,
+ ratio_vs_rush=1.5,
+ ratio_vs_leisure=0.5,
+ interpolation_t=0.5,
+ player_style="rush_to_leisure",
+ )
+ echoed = self._echoed(pace, rush=100.0, leisure=200.0)
+ assert any("150" in s for s in echoed)
+
+ def test_estimate_falls_back_to_ratio_when_no_interpolation(self) -> None:
+ # interpolation_t=-1, ratio_vs_rush=2.0, rush=100 → est=200
+ """Test estimate falls back to ratio when no interpolation."""
+ pace = PaceVsHLTB(
+ calibration_count=5,
+ ratio_vs_rush=2.0,
+ ratio_vs_leisure=-1.0,
+ interpolation_t=-1.0,
+ player_style="unknown",
+ )
+ echoed = self._echoed(pace, rush=100.0, leisure=0.0)
+ assert any("200" in s for s in echoed)
+
+ def test_no_estimate_when_both_methods_unavailable(self) -> None:
+ """No 'Estimated backlog total' line when t=-1 and ratio=-1."""
+ pace = PaceVsHLTB(
+ calibration_count=5,
+ ratio_vs_rush=-1.0,
+ ratio_vs_leisure=-1.0,
+ interpolation_t=-1.0,
+ player_style="unknown",
+ )
+ echoed = self._echoed(pace, rush=100.0, leisure=0.0)
+ assert not any("Estimated backlog total" in s for s in echoed)
+
+ def test_no_estimate_when_rush_total_zero_and_no_interpolation(self) -> None:
+ """No estimate line when rush_total=0 and interpolation_t=-1."""
+ pace = PaceVsHLTB(
+ calibration_count=5,
+ ratio_vs_rush=1.5,
+ ratio_vs_leisure=-1.0,
+ interpolation_t=-1.0,
+ player_style="unknown",
+ )
+ echoed = self._echoed(pace, rush=0.0, leisure=0.0)
+ assert not any("Estimated backlog total" in s for s in echoed)
diff --git a/steam_backlog_enforcer/tests/test_stats_print_worst_example.py b/steam_backlog_enforcer/tests/test_stats_print_worst_example.py
new file mode 100644
index 0000000..fdcea19
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_stats_print_worst_example.py
@@ -0,0 +1,162 @@
+"""Tests for _stats module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer._stats import (
+ _GameTimes,
+ _print_worst_example,
+)
+from steam_backlog_enforcer.protondb import ProtonDBRating
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_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 TestPrintWorstExample:
+ """Tests for _print_worst_example."""
+
+ def test_empty_list_does_nothing(self) -> None:
+ """Test empty list does nothing."""
+ 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:
+ """Test example with rush and leisure."""
+ entry = _GameTimes(
+ game=_game(name="Portal"),
+ worst_hours=15.0,
+ rush_hours=5.0,
+ leisure_100h=20.0,
+ hltb_game_id=99999,
+ )
+ 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:
+ """Test example without rush."""
+ entry = _GameTimes(
+ game=_game(name="X"),
+ worst_hours=15.0,
+ rush_hours=-1.0,
+ leisure_100h=20.0,
+ hltb_game_id=99999,
+ )
+ 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:
+ """Test example without leisure."""
+ entry = _GameTimes(
+ game=_game(name="Y"),
+ worst_hours=15.0,
+ rush_hours=5.0,
+ leisure_100h=-1.0,
+ hltb_game_id=99999,
+ )
+ 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_lookup_finds_nothing(self) -> None:
+ """Falls back to search URL when hltb_game_id is 0 and lookup finds nothing."""
+ 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])),
+ patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0),
+ patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}),
+ ):
+ _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_after_on_demand_lookup(self) -> None:
+ """Direct link shown when on-demand lookup successfully finds the game ID."""
+ entry = _GameTimes(
+ game=_game(app_id=111, 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])),
+ patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0),
+ patch(f"{_PKG}.load_hltb_game_id_cache", return_value={111: 42000}),
+ ):
+ _print_worst_example([entry])
+ assert any("howlongtobeat.com/game/42000" in s for s in echoed)
+ assert not any("?q=" 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,
+ hltb_game_id=99999,
+ )
+ 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)
diff --git a/steam_backlog_enforcer/tests/test_stats_refresh_recently_played_completions.py b/steam_backlog_enforcer/tests/test_stats_refresh_recently_played_completions.py
new file mode 100644
index 0000000..f945a1b
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_stats_refresh_recently_played_completions.py
@@ -0,0 +1,134 @@
+"""Tests for _stats module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer.config import Config
+from steam_backlog_enforcer.protondb import ProtonDBRating
+from steam_backlog_enforcer.steam_api import GameInfo, SteamAPIError
+
+_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 TestRefreshRecentlyPlayedCompletions:
+ """Tests for _refresh_recently_played_completions."""
+
+ def test_oserror_on_stat_returns_games_unchanged(self) -> None:
+ """Test oserror on stat returns games unchanged."""
+ games = [GameInfo(1, "G", 10, 0, 60)]
+ with patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf:
+ mock_sf.stat.side_effect = OSError("no file")
+ from steam_backlog_enforcer._stats import (
+ _refresh_recently_played_completions,
+ )
+
+ result = _refresh_recently_played_completions(games, Config())
+ assert result == games
+
+ def test_steam_api_error_returns_games_unchanged(self) -> None:
+ """A SteamAPIError while fetching owned games is swallowed."""
+ games = [GameInfo(1, "G", 10, 0, 60)]
+ with (
+ patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf,
+ patch(f"{_PKG}.SteamAPIClient") as mock_cls,
+ ):
+ mock_sf.stat.return_value.st_mtime = 1_000_000.0
+ mock_cls.return_value.get_owned_games.side_effect = SteamAPIError(
+ "api down"
+ )
+ from steam_backlog_enforcer._stats import (
+ _refresh_recently_played_completions,
+ )
+
+ result = _refresh_recently_played_completions(games, Config())
+ assert result == games
+
+ def test_no_recently_played_returns_games_unchanged(self) -> None:
+ """Test no recently played returns games unchanged."""
+ games = [GameInfo(1, "G", 10, 0, 60)]
+ with (
+ patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf,
+ patch(f"{_PKG}.SteamAPIClient") as mock_cls,
+ ):
+ mock_sf.stat.return_value.st_mtime = 1_000_000.0
+ mock_cls.return_value.get_owned_games.return_value = [
+ {"appid": 1, "rtime_last_played": 500_000}
+ ]
+ from steam_backlog_enforcer._stats import (
+ _refresh_recently_played_completions,
+ )
+
+ result = _refresh_recently_played_completions(games, Config())
+ assert result == games
+
+ def test_recently_played_game_is_refreshed(self) -> None:
+ """Test recently played game is refreshed."""
+ from steam_backlog_enforcer._stats import _refresh_recently_played_completions
+ from steam_backlog_enforcer.steam_api import AchievementInfo
+
+ game = GameInfo(1, "G", 5, 0, 60)
+ new_achievements = [
+ AchievementInfo("a1", "A1", achieved=True, unlock_time=1_500_001),
+ AchievementInfo("a2", "A2", achieved=True, unlock_time=1_500_002),
+ AchievementInfo("a3", "A3", achieved=False, unlock_time=0),
+ AchievementInfo("a4", "A4", achieved=False, unlock_time=0),
+ AchievementInfo("a5", "A5", achieved=False, unlock_time=0),
+ ]
+ with (
+ patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf,
+ patch(f"{_PKG}.SteamAPIClient") as mock_cls,
+ patch(f"{_PKG}._echo"),
+ ):
+ mock_sf.stat.return_value.st_mtime = 1_000_000.0
+ mock_cls.return_value.get_owned_games.return_value = [
+ {"appid": 1, "rtime_last_played": 1_500_000}
+ ]
+ mock_cls.return_value.get_achievement_details.return_value = (
+ new_achievements
+ )
+ result = _refresh_recently_played_completions([game], Config())
+ refreshed = next(g for g in result if g.app_id == 1)
+ assert refreshed.unlocked_achievements == 2
+
+ def test_get_achievement_details_empty_keeps_old_game(self) -> None:
+ """Test get achievement details empty keeps old game."""
+ from steam_backlog_enforcer._stats import _refresh_recently_played_completions
+
+ game = GameInfo(1, "G", 5, 3, 60)
+ with (
+ patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf,
+ patch(f"{_PKG}.SteamAPIClient") as mock_cls,
+ patch(f"{_PKG}._echo"),
+ ):
+ mock_sf.stat.return_value.st_mtime = 1_000_000.0
+ mock_cls.return_value.get_owned_games.return_value = [
+ {"appid": 1, "rtime_last_played": 1_500_000}
+ ]
+ mock_cls.return_value.get_achievement_details.return_value = []
+ result = _refresh_recently_played_completions([game], Config())
+ refreshed = next(g for g in result if g.app_id == 1)
+ assert refreshed.unlocked_achievements == 3
diff --git a/steam_backlog_enforcer/tests/test_stats_sum_hours.py b/steam_backlog_enforcer/tests/test_stats_sum_hours.py
new file mode 100644
index 0000000..e53e69f
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_stats_sum_hours.py
@@ -0,0 +1,177 @@
+"""Tests for _stats module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+from steam_backlog_enforcer._stats import (
+ _format_completion_date,
+ _GameTimes,
+ _print_pace_scenario,
+ _print_scenario,
+ _sum_hours,
+)
+from steam_backlog_enforcer.config import State
+from steam_backlog_enforcer.protondb import ProtonDBRating
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_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 TestSumHours:
+ """Tests for _sum_hours."""
+
+ def _make_entry(self, worst: float, rush: float, leisure: float) -> _GameTimes:
+ """Test make entry."""
+ return _GameTimes(
+ game=_game(), worst_hours=worst, rush_hours=rush, leisure_100h=leisure
+ )
+
+ def test_empty_list(self) -> None:
+ """Test empty list."""
+ total, missing = _sum_hours([], "worst_hours")
+ assert total == 0.0
+ assert missing == 0
+
+ def test_all_positive(self) -> None:
+ """Test all positive."""
+ 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:
+ """Test some negative."""
+ 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:
+ """Test all negative."""
+ 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:
+ """Test zero hours returns na."""
+ assert _format_completion_date(0.0, 4.0) == "N/A"
+
+ def test_negative_hours_returns_na(self) -> None:
+ """Test negative hours returns na."""
+ assert _format_completion_date(-5.0, 4.0) == "N/A"
+
+ def test_zero_daily_hours_returns_na(self) -> None:
+ """Test zero daily hours returns na."""
+ assert _format_completion_date(100.0, 0.0) == "N/A"
+
+ def test_negative_daily_hours_returns_na(self) -> None:
+ """Test negative daily hours returns na."""
+ assert _format_completion_date(100.0, -1.0) == "N/A"
+
+ def test_normal_returns_days_and_date(self) -> None:
+ """Test normal returns days and date."""
+ 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:
+ """Test no data prints no data message."""
+ 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:
+ """Test with data no missing."""
+ 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:
+ """Test with data and missing."""
+ 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:
+ """Test no start date."""
+ 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:
+ """Test invalid start date."""
+ 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:
+ """Test no games finished."""
+ 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:
+ """Test normal pace."""
+ 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)
diff --git a/steam_backlog_enforcer/tests/test_stats_unplayable_rating_counts_linux_skipped.py b/steam_backlog_enforcer/tests/test_stats_unplayable_rating_counts_linux_skipped.py
new file mode 100644
index 0000000..3efa6c4
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_stats_unplayable_rating_counts_linux_skipped.py
@@ -0,0 +1,166 @@
+"""Tests for _stats module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer._stats import (
+ _filter_qualifying_games,
+ _GameTimes,
+)
+from steam_backlog_enforcer.config import State
+from steam_backlog_enforcer.protondb import ProtonDBRating
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_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 TestFilterQualifyingGamesGroup2:
+ """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]:
+ """Test run."""
+ 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_unplayable_rating_counts_linux_skipped(self) -> None:
+ """Test unplayable rating counts linux skipped."""
+ 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:
+ """Test worst hours positive when completionist hours positive."""
+ 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:
+ """Test rush and leisure from cache."""
+ 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:
+ """Test missing cache entry defaults to minus one."""
+ 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
diff --git a/steam_backlog_enforcer/tests/test_steam_api.py b/steam_backlog_enforcer/tests/test_steam_api.py
index 25c1e60..44c5e56 100644
--- a/steam_backlog_enforcer/tests/test_steam_api.py
+++ b/steam_backlog_enforcer/tests/test_steam_api.py
@@ -3,16 +3,10 @@
from __future__ import annotations
from typing import Any
-from unittest.mock import MagicMock, patch
-
-import pytest
-import requests
from steam_backlog_enforcer.steam_api import (
AchievementInfo,
GameInfo,
- SteamAPIClient,
- SteamAPIError,
)
@@ -20,6 +14,7 @@ class TestAchievementInfo:
"""Tests for AchievementInfo."""
def test_create(self) -> None:
+ """Test create."""
a = AchievementInfo(
api_name="ACH_1", display_name="First", achieved=True, unlock_time=1000
)
@@ -31,6 +26,7 @@ class TestGameInfo:
"""Tests for GameInfo."""
def test_completion_pct_zero_achievements(self) -> None:
+ """Test completion pct zero achievements."""
g = GameInfo(
app_id=1,
name="G",
@@ -41,6 +37,7 @@ class TestGameInfo:
assert g.completion_pct == 100.0
def test_completion_pct_partial(self) -> None:
+ """Test completion pct partial."""
g = GameInfo(
app_id=1,
name="G",
@@ -51,6 +48,7 @@ class TestGameInfo:
assert g.completion_pct == 50.0
def test_is_complete_true(self) -> None:
+ """Test is complete true."""
g = GameInfo(
app_id=1,
name="G",
@@ -61,6 +59,7 @@ class TestGameInfo:
assert g.is_complete is True
def test_is_complete_false(self) -> None:
+ """Test is complete false."""
g = GameInfo(
app_id=1,
name="G",
@@ -71,6 +70,7 @@ class TestGameInfo:
assert g.is_complete is False
def test_is_complete_zero(self) -> None:
+ """Test is complete zero."""
g = GameInfo(
app_id=1,
name="G",
@@ -81,6 +81,7 @@ class TestGameInfo:
assert g.is_complete is False
def test_to_snapshot(self) -> None:
+ """Test to snapshot."""
ach = AchievementInfo(
api_name="A1", display_name="Ach1", achieved=True, unlock_time=99
)
@@ -99,6 +100,7 @@ class TestGameInfo:
assert snap["completionist_hours"] == 5.0
def test_from_snapshot(self) -> None:
+ """Test from snapshot."""
data: dict[str, Any] = {
"app_id": 2,
"name": "G2",
@@ -121,6 +123,7 @@ class TestGameInfo:
assert len(g.achievements) == 1
def test_from_snapshot_defaults(self) -> None:
+ """Test from snapshot defaults."""
data: dict[str, Any] = {
"app_id": 3,
"name": "G3",
@@ -133,6 +136,7 @@ class TestGameInfo:
assert g.achievements == []
def test_from_snapshot_achievement_defaults(self) -> None:
+ """Test from snapshot achievement defaults."""
data: dict[str, Any] = {
"app_id": 4,
"name": "G4",
@@ -143,191 +147,3 @@ class TestGameInfo:
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(
- "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
diff --git a/steam_backlog_enforcer/tests/test_steam_api_steam_a_p_i_client.py b/steam_backlog_enforcer/tests/test_steam_api_steam_a_p_i_client.py
new file mode 100644
index 0000000..f616c61
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_steam_api_steam_a_p_i_client.py
@@ -0,0 +1,220 @@
+"""Tests for steam_api module."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+import requests
+
+from steam_backlog_enforcer.steam_api import (
+ AchievementInfo,
+ SteamAPIClient,
+ SteamAPIError,
+)
+
+
+class TestSteamAPIClient:
+ """Tests for SteamAPIClient."""
+
+ def test_init(self) -> None:
+ """Test init."""
+ client = SteamAPIClient("key", "id")
+ assert client.api_key == "key"
+ assert client.steam_id == "id"
+
+ def test_rate_limit(self) -> None:
+ """Test rate limit."""
+ client = SteamAPIClient("key", "id")
+ # Should not block on first call
+ client._rate_limit()
+
+ def test_rate_limit_throttle(self) -> None:
+ """Test rate limit throttle."""
+ client = SteamAPIClient("key", "id")
+ # Fill up the rate limit window
+ client._request_times = [__import__("time").time()] * client._max_rps
+ with patch(
+ "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:
+ """Test get success."""
+ 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:
+ """Test get with params."""
+ 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:
+ """Test get failure."""
+ 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:
+ """Test get owned games."""
+ 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:
+ """Test get owned games empty."""
+ 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:
+ """Test get achievement details."""
+ 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:
+ """Test get achievement details failure."""
+ 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:
+ """Test get achievement details not success."""
+ 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:
+ """Test fetch one game."""
+ 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:
+ """Test fetch one game no achievements."""
+ 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:
+ """Test build game list."""
+ 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:
+ """Test progress."""
+ 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:
+ """Test build game list exception in future."""
+ 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:
+ """Test refresh single game."""
+ 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:
+ """Test refresh single game no achievements."""
+ client = SteamAPIClient("key", "id")
+ with patch.object(client, "get_achievement_details", return_value=[]):
+ result = client.refresh_single_game(440, "TF2")
+ assert result is None
diff --git a/steam_backlog_enforcer/tests/test_store_blocker.py b/steam_backlog_enforcer/tests/test_store_blocker.py
index 4cac8fd..b8d0422 100644
--- a/steam_backlog_enforcer/tests/test_store_blocker.py
+++ b/steam_backlog_enforcer/tests/test_store_blocker.py
@@ -3,16 +3,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING
-from unittest.mock import MagicMock, patch
+from unittest.mock import patch
from 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:
@@ -23,6 +18,7 @@ class TestIsStoreBlocked:
"""Tests for is_store_blocked."""
def test_blocked_in_hosts(self, tmp_path: Path) -> None:
+ """Test blocked in hosts."""
hosts_file = tmp_path / "hosts"
hosts_file.write_text("0.0.0.0 store.steampowered.com\n", encoding="utf-8")
with (
@@ -34,6 +30,7 @@ class TestIsStoreBlocked:
assert is_store_blocked() is True
def test_commented_in_hosts(self, tmp_path: Path) -> None:
+ """Test commented in hosts."""
hosts_file = tmp_path / "hosts"
hosts_file.write_text("# 0.0.0.0 store.steampowered.com\n", encoding="utf-8")
with (
@@ -49,6 +46,7 @@ class TestIsStoreBlocked:
assert is_store_blocked() is False
def test_not_in_hosts_iptables_blocked(self, tmp_path: Path) -> None:
+ """Test not in hosts iptables blocked."""
hosts_file = tmp_path / "hosts"
hosts_file.write_text("127.0.0.1 localhost\n", encoding="utf-8")
with (
@@ -64,6 +62,7 @@ class TestIsStoreBlocked:
assert is_store_blocked() is True
def test_hosts_read_error(self, tmp_path: Path) -> None:
+ """Test hosts read error."""
hosts_file = tmp_path / "nonexistent"
with (
patch(
@@ -78,6 +77,7 @@ class TestIsStoreBlocked:
assert is_store_blocked() is False
def test_wrong_redirect_ip(self, tmp_path: Path) -> None:
+ """Test wrong redirect ip."""
hosts_file = tmp_path / "hosts"
hosts_file.write_text("127.0.0.1 store.steampowered.com\n", encoding="utf-8")
with (
@@ -97,6 +97,7 @@ class TestBlockStore:
"""Tests for block_store."""
def test_already_blocked(self) -> None:
+ """Test already blocked."""
with patch(
"steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=True,
@@ -104,6 +105,7 @@ class TestBlockStore:
assert block_store() is True
def test_reblock_succeeds(self) -> None:
+ """Test reblock succeeds."""
with (
patch(
"steam_backlog_enforcer.store_blocker.is_store_blocked",
@@ -123,6 +125,7 @@ class TestBlockStore:
assert block_store() is True
def test_fallback_to_install_script(self) -> None:
+ """Test fallback to install script."""
with (
patch(
"steam_backlog_enforcer.store_blocker.is_store_blocked",
@@ -147,6 +150,7 @@ class TestBlockStore:
assert block_store() is True
def test_all_fail(self) -> None:
+ """Test all fail."""
with (
patch(
"steam_backlog_enforcer.store_blocker.is_store_blocked",
@@ -168,6 +172,7 @@ class TestBlockStore:
assert block_store() is False
def test_iptables_only_succeeds(self) -> None:
+ """Test iptables only succeeds."""
with (
patch(
"steam_backlog_enforcer.store_blocker.is_store_blocked",
@@ -190,281 +195,3 @@ class TestBlockStore:
),
):
assert block_store() is True
-
-
-class TestBlockViaHostsInstall:
- """Tests for _block_via_hosts_install."""
-
- def test_already_blocked(self) -> None:
- with patch(
- "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(
- "steam_backlog_enforcer.store_blocker.is_store_blocked",
- return_value=False,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.store_blocker.is_store_blocked",
- return_value=False,
- ),
- patch(
- "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
- script,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.store_blocker.is_store_blocked",
- return_value=False,
- ),
- patch(
- "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
- script,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.store_blocker.is_store_blocked",
- return_value=False,
- ),
- patch(
- "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
- script,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.store_blocker.is_store_blocked",
- return_value=False,
- ),
- patch(
- "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
- script,
- ),
- patch(
- "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(
- "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(
- "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(
- "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(
- "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(
- "steam_backlog_enforcer.store_blocker.subprocess.run",
- return_value=mock_result,
- ),
- patch(
- "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(
- "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(
- "steam_backlog_enforcer.store_blocker.subprocess.run",
- return_value=mock_result,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.store_blocker.subprocess.run",
- side_effect=side_effect,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.store_blocker._unblock_store_iptables",
- return_value=True,
- ),
- patch(
- "steam_backlog_enforcer.store_blocker._unblock_hosts",
- return_value=True,
- ),
- patch(
- "steam_backlog_enforcer.store_blocker.flush_dns_cache",
- ),
- ):
- assert unblock_store() is True
-
- def test_iptables_fails(self) -> None:
- with (
- patch(
- "steam_backlog_enforcer.store_blocker._unblock_store_iptables",
- return_value=False,
- ),
- patch(
- "steam_backlog_enforcer.store_blocker._unblock_hosts",
- return_value=True,
- ),
- patch(
- "steam_backlog_enforcer.store_blocker.flush_dns_cache",
- ),
- ):
- assert unblock_store() is True
-
- def test_both_fail(self) -> None:
- with (
- patch(
- "steam_backlog_enforcer.store_blocker._unblock_store_iptables",
- return_value=False,
- ),
- patch(
- "steam_backlog_enforcer.store_blocker._unblock_hosts",
- return_value=False,
- ),
- patch(
- "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(
- "steam_backlog_enforcer.store_blocker.subprocess.run",
- ):
- assert _unblock_store_iptables() is True
-
- def test_os_error(self) -> None:
- with patch(
- "steam_backlog_enforcer.store_blocker.subprocess.run",
- side_effect=OSError,
- ):
- assert _unblock_store_iptables() is False
diff --git a/steam_backlog_enforcer/tests/test_store_blocker_block_via_hosts_install.py b/steam_backlog_enforcer/tests/test_store_blocker_block_via_hosts_install.py
new file mode 100644
index 0000000..764c396
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_store_blocker_block_via_hosts_install.py
@@ -0,0 +1,239 @@
+"""Tests for store_blocker module."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from unittest.mock import MagicMock, patch
+
+from steam_backlog_enforcer.store_blocker import (
+ _block_store_iptables,
+ _block_via_hosts_install,
+ _is_iptables_blocked,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+class TestBlockViaHostsInstall:
+ """Tests for _block_via_hosts_install."""
+
+ def test_already_blocked(self) -> None:
+ """Test already blocked."""
+ with patch(
+ "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:
+ """Test script missing."""
+ with (
+ patch(
+ "steam_backlog_enforcer.store_blocker.is_store_blocked",
+ return_value=False,
+ ),
+ patch(
+ "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:
+ """Test script succeeds."""
+ script = tmp_path / "install.sh"
+ script.touch()
+ mock_result = MagicMock(returncode=0)
+ with (
+ patch(
+ "steam_backlog_enforcer.store_blocker.is_store_blocked",
+ return_value=False,
+ ),
+ patch(
+ "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
+ script,
+ ),
+ patch(
+ "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:
+ """Test script fails."""
+ script = tmp_path / "install.sh"
+ script.touch()
+ mock_result = MagicMock(returncode=1, stderr="error", stdout="")
+ with (
+ patch(
+ "steam_backlog_enforcer.store_blocker.is_store_blocked",
+ return_value=False,
+ ),
+ patch(
+ "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
+ script,
+ ),
+ patch(
+ "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:
+ """Test script fails no stderr."""
+ script = tmp_path / "install.sh"
+ script.touch()
+ mock_result = MagicMock(returncode=1, stderr="", stdout="out")
+ with (
+ patch(
+ "steam_backlog_enforcer.store_blocker.is_store_blocked",
+ return_value=False,
+ ),
+ patch(
+ "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
+ script,
+ ),
+ patch(
+ "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:
+ """Test script os error."""
+ script = tmp_path / "install.sh"
+ script.touch()
+ with (
+ patch(
+ "steam_backlog_enforcer.store_blocker.is_store_blocked",
+ return_value=False,
+ ),
+ patch(
+ "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
+ script,
+ ),
+ patch(
+ "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:
+ """Test blocked."""
+ mock_result = MagicMock(returncode=0, stdout="DROP blah")
+ with patch(
+ "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:
+ """Test not blocked no drop."""
+ mock_result = MagicMock(returncode=0, stdout="ACCEPT")
+ with patch(
+ "steam_backlog_enforcer.store_blocker.subprocess.run",
+ return_value=mock_result,
+ ):
+ assert _is_iptables_blocked() is False
+
+ def test_not_blocked_error(self) -> None:
+ """Test not blocked error."""
+ mock_result = MagicMock(returncode=1, stdout="")
+ with patch(
+ "steam_backlog_enforcer.store_blocker.subprocess.run",
+ return_value=mock_result,
+ ):
+ assert _is_iptables_blocked() is False
+
+ def test_os_error(self) -> None:
+ """Test os error."""
+ with patch(
+ "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:
+ """Test success."""
+ mock_result = MagicMock(returncode=0)
+ with (
+ patch(
+ "steam_backlog_enforcer.store_blocker.subprocess.run",
+ return_value=mock_result,
+ ),
+ patch(
+ "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:
+ """Test os error."""
+ with patch(
+ "steam_backlog_enforcer.store_blocker.subprocess.run",
+ side_effect=OSError,
+ ):
+ assert _block_store_iptables() is False
+
+ def test_dns_resolution_fails(self) -> None:
+ """Test dns resolution fails."""
+ import socket
+
+ mock_result = MagicMock(returncode=0)
+ with (
+ patch(
+ "steam_backlog_enforcer.store_blocker.subprocess.run",
+ return_value=mock_result,
+ ),
+ patch(
+ "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:
+ """Test chain hook needed."""
+ 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:
+ """Test side effect."""
+ nonlocal call_count
+ idx = min(call_count, len(results) - 1)
+ call_count += 1
+ return results[idx]
+
+ with (
+ patch(
+ "steam_backlog_enforcer.store_blocker.subprocess.run",
+ side_effect=side_effect,
+ ),
+ patch(
+ "steam_backlog_enforcer.store_blocker.socket.getaddrinfo",
+ side_effect=__import__("socket").gaierror,
+ ),
+ ):
+ assert _block_store_iptables() is True
diff --git a/steam_backlog_enforcer/tests/test_store_blocker_unblock_store.py b/steam_backlog_enforcer/tests/test_store_blocker_unblock_store.py
new file mode 100644
index 0000000..f31265f
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_store_blocker_unblock_store.py
@@ -0,0 +1,84 @@
+"""Tests for store_blocker module."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from steam_backlog_enforcer.store_blocker import (
+ _unblock_store_iptables,
+ unblock_store,
+)
+
+
+class TestUnblockStore:
+ """Tests for unblock_store."""
+
+ def test_both_succeed(self) -> None:
+ """Test both succeed."""
+ with (
+ patch(
+ "steam_backlog_enforcer.store_blocker._unblock_store_iptables",
+ return_value=True,
+ ),
+ patch(
+ "steam_backlog_enforcer.store_blocker._unblock_hosts",
+ return_value=True,
+ ),
+ patch(
+ "steam_backlog_enforcer.store_blocker.flush_dns_cache",
+ ),
+ ):
+ assert unblock_store() is True
+
+ def test_iptables_fails(self) -> None:
+ """Test iptables fails."""
+ with (
+ patch(
+ "steam_backlog_enforcer.store_blocker._unblock_store_iptables",
+ return_value=False,
+ ),
+ patch(
+ "steam_backlog_enforcer.store_blocker._unblock_hosts",
+ return_value=True,
+ ),
+ patch(
+ "steam_backlog_enforcer.store_blocker.flush_dns_cache",
+ ),
+ ):
+ assert unblock_store() is True
+
+ def test_both_fail(self) -> None:
+ """Test both fail."""
+ with (
+ patch(
+ "steam_backlog_enforcer.store_blocker._unblock_store_iptables",
+ return_value=False,
+ ),
+ patch(
+ "steam_backlog_enforcer.store_blocker._unblock_hosts",
+ return_value=False,
+ ),
+ patch(
+ "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:
+ """Test success."""
+ with patch(
+ "steam_backlog_enforcer.store_blocker.subprocess.run",
+ ):
+ assert _unblock_store_iptables() is True
+
+ def test_os_error(self) -> None:
+ """Test os error."""
+ with patch(
+ "steam_backlog_enforcer.store_blocker.subprocess.run",
+ side_effect=OSError,
+ ):
+ assert _unblock_store_iptables() is False
diff --git a/steam_backlog_enforcer/tests/test_web_dataset.py b/steam_backlog_enforcer/tests/test_web_dataset.py
index 68e6cda..40553fe 100644
--- a/steam_backlog_enforcer/tests/test_web_dataset.py
+++ b/steam_backlog_enforcer/tests/test_web_dataset.py
@@ -3,27 +3,16 @@
from __future__ import annotations
from dataclasses import replace
-from datetime import datetime, timedelta, timezone
-from unittest.mock import patch
from steam_backlog_enforcer._web_dataset import (
- HOURS_PER_DAY_PRESETS,
- PaceVsHLTB,
WebGame,
- _build_games,
_default_qualifying,
_default_summary,
_has_any_time,
_passes_default_confidence,
- _state_info,
_sum_positive,
_worst_hours,
- build_web_dataset,
- compute_pace_vs_hltb,
- count_complete_since_start,
- dataset_to_payload,
)
-from steam_backlog_enforcer.config import State
from steam_backlog_enforcer.steam_api import GameInfo
_PKG = "steam_backlog_enforcer._web_dataset"
@@ -64,374 +53,6 @@ def _wg(**over: object) -> WebGame:
return replace(base, **over)
-class TestWorstHours:
- """Tests for _worst_hours (mirrors _stats worst-case selection)."""
-
- def test_completionist_dominates(self) -> None:
- game = _gi(completionist_hours=30.0)
- assert _worst_hours(game, cache_hours=10.0, leisure=20.0) == 30.0
-
- def test_falls_back_to_cache_and_leisure_when_completionist_zero(self) -> None:
- game = _gi(completionist_hours=0.0)
- assert _worst_hours(game, cache_hours=15.0, leisure=8.0) == 15.0
-
- def test_minus_one_when_all_non_positive(self) -> None:
- game = _gi(completionist_hours=0.0)
- assert _worst_hours(game, cache_hours=-1.0, leisure=-1.0) == -1.0
-
-
-class TestPassesDefaultConfidence:
- """Tests for _passes_default_confidence."""
-
- def test_fail_low_comp_100(self) -> None:
- assert _passes_default_confidence(_wg(comp_100_count=2)) is False
-
- def test_fail_low_count_comp(self) -> None:
- assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=10)) is False
-
- def test_pass_when_all_thresholds_met(self) -> None:
- assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=20)) is True
-
-
-class TestHasAnyTime:
- """Tests for _has_any_time."""
-
- def test_true_when_some_positive(self) -> None:
- assert _has_any_time(_wg(rush_hours=-1, leisure_hours=-1, worst_hours=5.0))
-
- def test_false_when_all_non_positive(self) -> None:
- game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1)
- assert _has_any_time(game) is False
-
-
-class TestDefaultQualifying:
- """Tests for _default_qualifying — each filter rejection branch."""
-
- def test_rejects_low_confidence(self) -> None:
- assert _default_qualifying([_wg(count_comp=0)]) == []
-
- def test_rejects_unplayable(self) -> None:
- game = _wg(protondb_tier="borked", protondb_trending_tier="borked")
- assert _default_qualifying([game]) == []
-
- def test_rejects_no_time(self) -> None:
- game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1)
- assert _default_qualifying([game]) == []
-
- def test_accepts_qualifying_game(self) -> None:
- assert len(_default_qualifying([_wg()])) == 1
-
-
-class TestSumPositive:
- """Tests for _sum_positive."""
-
- def test_sums_only_positive(self) -> None:
- rows = [_wg(rush_hours=10.0), _wg(rush_hours=-1.0), _wg(rush_hours=5.5)]
- assert _sum_positive(rows, "rush_hours") == 15.5
-
- def test_empty(self) -> None:
- assert _sum_positive([], "rush_hours") == 0.0
-
-
-class TestDefaultSummary:
- """Tests for _default_summary."""
-
- def test_totals(self) -> None:
- rows = [_wg(rush_hours=10.0, leisure_hours=20.0, worst_hours=25.0)]
- summary = _default_summary(rows)
- assert summary.qualifying == 1
- assert summary.rush_total == 10.0
- assert summary.leisure_total == 20.0
- assert summary.worst_total == 25.0
-
-
-class TestCountCompleteSinceStart:
- """Tests for count_complete_since_start."""
-
- def _ach(self, ts: int, *, achieved: bool = True) -> object:
- from steam_backlog_enforcer.steam_api import AchievementInfo
-
- return AchievementInfo(
- api_name="A", display_name="A", achieved=achieved, unlock_time=ts
- )
-
- def _complete_game(self, app_id: int, unlock_ts: int) -> GameInfo:
- achs = [self._ach(unlock_ts)] * 5
- return _gi(
- app_id=app_id,
- total_achievements=5,
- unlocked_achievements=5,
- achievements=achs,
- )
-
- def test_empty_started_at_returns_zero(self) -> None:
- games = [self._complete_game(1, 1_000_000)]
- assert count_complete_since_start(games, "") == 0
-
- def test_invalid_started_at_returns_zero(self) -> None:
- games = [self._complete_game(1, 1_000_000)]
- assert count_complete_since_start(games, "not-a-date") == 0
-
- def test_counts_game_completed_after_start(self) -> None:
- started = datetime(2026, 1, 1, tzinfo=timezone.utc)
- after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp())
- games = [self._complete_game(1, after_ts)]
- assert count_complete_since_start(games, started.isoformat()) == 1
-
- def test_excludes_game_completed_before_start(self) -> None:
- started = datetime(2026, 1, 1, tzinfo=timezone.utc)
- before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp())
- games = [self._complete_game(1, before_ts)]
- assert count_complete_since_start(games, started.isoformat()) == 0
-
- def test_excludes_incomplete_game(self) -> None:
- started = datetime(2026, 1, 1, tzinfo=timezone.utc)
- after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp())
- incomplete = _gi(
- app_id=1,
- total_achievements=5,
- unlocked_achievements=3,
- achievements=[self._ach(after_ts)] * 3,
- )
- assert count_complete_since_start([incomplete], started.isoformat()) == 0
-
- def test_excludes_game_with_no_achievement_timestamps(self) -> None:
- """Complete game with unlock_time=0 on all achievements is excluded."""
- started = datetime(2026, 1, 1, tzinfo=timezone.utc)
- no_ts = _gi(
- app_id=1,
- total_achievements=5,
- unlocked_achievements=5,
- achievements=[self._ach(0)] * 5,
- )
- assert count_complete_since_start([no_ts], started.isoformat()) == 0
-
- def test_mixed_games_counts_only_post_start(self) -> None:
- started = datetime(2026, 1, 1, tzinfo=timezone.utc)
- after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp())
- before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp())
- games = [
- self._complete_game(1, after_ts),
- self._complete_game(2, before_ts),
- self._complete_game(3, after_ts),
- ]
- assert count_complete_since_start(games, started.isoformat()) == 2
-
- def test_uses_max_unlock_time_across_achievements(self) -> None:
- """Game counts if its LAST achievement was unlocked after start."""
- started = datetime(2026, 1, 1, tzinfo=timezone.utc)
- before_ts = int(datetime(2025, 12, 1, tzinfo=timezone.utc).timestamp())
- after_ts = int(datetime(2026, 2, 1, tzinfo=timezone.utc).timestamp())
- # Mix of before/after timestamps — max is after start, so should count
- achs = [self._ach(before_ts)] * 4 + [self._ach(after_ts)]
- game = _gi(
- app_id=1, total_achievements=5, unlocked_achievements=5, achievements=achs
- )
- assert count_complete_since_start([game], started.isoformat()) == 1
-
-
-class TestStateInfo:
- """Tests for _state_info pace calculation."""
-
- def test_no_start_date(self) -> None:
- info = _state_info(State(), games_done=5, games_done_since_start=5)
- assert info.days_elapsed == 0
- assert info.pace_games_per_day == 0.0
- assert info.games_done == 5
- assert info.games_done_since_start == 5
-
- def test_invalid_start_date(self) -> None:
- info = _state_info(
- State(enforcement_started_at="not-a-date"),
- games_done=5,
- games_done_since_start=5,
- )
- assert info.days_elapsed == 0
- assert info.pace_games_per_day == 0.0
-
- def test_valid_start_with_games(self) -> None:
- started = datetime.now(timezone.utc) - timedelta(days=50)
- info = _state_info(
- State(enforcement_started_at=started.isoformat()),
- games_done=12,
- games_done_since_start=10,
- )
- assert info.days_elapsed >= 49
- assert info.pace_games_per_day > 0.0
- assert info.games_done == 12
- assert info.games_done_since_start == 10
-
- def test_valid_start_zero_since_start_keeps_zero_pace(self) -> None:
- """games_done_since_start=0 → pace stays 0 even if total games_done > 0."""
- started = datetime.now(timezone.utc) - timedelta(days=50)
- info = _state_info(
- State(enforcement_started_at=started.isoformat()),
- games_done=5,
- games_done_since_start=0,
- )
- assert info.days_elapsed >= 49
- assert info.pace_games_per_day == 0.0
-
-
-class TestBuildGames:
- """Tests for _build_games (patches cache loaders, no file I/O)."""
-
- def _run(
- self,
- games: list[GameInfo],
- exclude: set[int],
- raw: dict[int, dict[str, object]] | None = None,
- protondb: dict[str, dict[str, object]] | None = None,
- ) -> list[WebGame]:
- with (
- patch(f"{_PKG}._read_raw_cache", return_value=raw or {}),
- patch(f"{_PKG}._load_cache", return_value=protondb or {}),
- ):
- return _build_games(games, exclude)
-
- def test_skips_complete_games(self) -> None:
- rows = self._run(
- [_gi(app_id=1, total_achievements=5, unlocked_achievements=5)], set()
- )
- assert rows == []
-
- def test_skips_excluded_games(self) -> None:
- assert self._run([_gi(app_id=1)], {1}) == []
-
- def test_uses_cache_entry_when_present(self) -> None:
- raw = {
- 1: {
- "hours": 18.0,
- "polls": 7,
- "count_comp": 30,
- "rush_hours": 9.0,
- "leisure_100h": 22.0,
- "hltb_game_id": 555,
- }
- }
- proton = {"1": {"tier": "platinum", "trending_tier": "gold", "score": 0.9}}
- rows = self._run([_gi(app_id=1, completionist_hours=0.0)], set(), raw, proton)
- assert len(rows) == 1
- row = rows[0]
- assert row.rush_hours == 9.0
- assert row.leisure_hours == 22.0
- assert row.worst_hours == 22.0 # max(cache 18, leisure 22)
- assert row.count_comp == 30
- assert row.comp_100_count == 7
- assert row.hltb_game_id == 555
- assert row.protondb_tier == "platinum"
- assert row.protondb_trending_tier == "gold"
-
- def test_defaults_when_no_cache_entries(self) -> None:
- rows = self._run([_gi(app_id=1, completionist_hours=12.0)], set())
- assert len(rows) == 1
- row = rows[0]
- assert row.rush_hours == -1
- assert row.leisure_hours == -1
- assert row.worst_hours == 12.0 # completionist only
- assert row.protondb_tier == "" # no protondb entry
-
-
-class TestBuildWebDataset:
- """Tests for build_web_dataset (top-level projection)."""
-
- def test_no_snapshot_returns_empty_games(self) -> None:
- with (
- patch(f"{_PKG}.load_snapshot", return_value=None),
- patch(f"{_PKG}._read_raw_cache", return_value={}),
- patch(f"{_PKG}._load_cache", return_value={}),
- ):
- ds = build_web_dataset(State())
- assert ds.games == []
- assert ds.state.games_done == 0
- assert ds.default_summary.qualifying == 0
- assert ds.defaults.hours_per_day_presets == list(HOURS_PER_DAY_PRESETS)
-
- def test_excludes_current_app_id(self) -> None:
- snapshot = [_gi(app_id=1).to_snapshot(), _gi(app_id=2).to_snapshot()]
- raw = {
- aid: {
- "hours": -1,
- "polls": 5,
- "count_comp": 20,
- "rush_hours": 10.0,
- "leisure_100h": 25.0,
- "hltb_game_id": 0,
- }
- for aid in (1, 2)
- }
- proton = {str(a): {"tier": "gold", "trending_tier": "gold"} for a in (1, 2)}
- with (
- patch(f"{_PKG}.load_snapshot", return_value=snapshot),
- patch(f"{_PKG}._read_raw_cache", return_value=raw),
- patch(f"{_PKG}._load_cache", return_value=proton),
- ):
- ds = build_web_dataset(State(current_app_id=1))
- assert [g.app_id for g in ds.games] == [2]
-
- def test_parity_mini_oracle(self) -> None:
- """A small hand-checked dataset reproduces qualifying + totals."""
- # g1 qualifies; g2 fails confidence; g3 is complete (excluded).
- snapshot = [
- _gi(app_id=1, completionist_hours=0.0).to_snapshot(),
- _gi(app_id=2, completionist_hours=0.0).to_snapshot(),
- _gi(app_id=3, total_achievements=5, unlocked_achievements=5).to_snapshot(),
- ]
- raw = {
- 1: {
- "hours": -1,
- "polls": 5,
- "count_comp": 20,
- "rush_hours": 10.0,
- "leisure_100h": 25.0,
- "hltb_game_id": 0,
- },
- 2: {
- "hours": -1,
- "polls": 5,
- "count_comp": 0, # fails count_comp threshold
- "rush_hours": 10.0,
- "leisure_100h": 25.0,
- "hltb_game_id": 0,
- },
- }
- proton = {"1": {"tier": "gold", "trending_tier": "gold"}}
- with (
- patch(f"{_PKG}.load_snapshot", return_value=snapshot),
- patch(f"{_PKG}._read_raw_cache", return_value=raw),
- patch(f"{_PKG}._load_cache", return_value=proton),
- ):
- ds = build_web_dataset(State())
- assert ds.state.games_done == 1 # g3 complete
- assert len(ds.games) == 2 # g1 + g2 candidates, g3 excluded
- assert ds.default_summary.qualifying == 1 # only g1
- assert ds.default_summary.rush_total == 10.0
- assert ds.default_summary.leisure_total == 25.0
- assert ds.default_summary.worst_total == 25.0
-
-
-class TestDatasetToPayload:
- """Tests for dataset_to_payload."""
-
- def test_serializes_to_dict(self) -> None:
- with (
- patch(f"{_PKG}.load_snapshot", return_value=None),
- patch(f"{_PKG}._read_raw_cache", return_value={}),
- patch(f"{_PKG}._load_cache", return_value={}),
- ):
- payload = dataset_to_payload(build_web_dataset(State()))
- assert set(payload) == {
- "games",
- "state",
- "defaults",
- "default_summary",
- "pace_vs_hltb",
- "generated_at",
- }
- assert isinstance(payload["games"], list)
- assert isinstance(payload["state"], dict)
-
-
def _complete_game(
app_id: int = 1,
playtime_minutes: int = 600,
@@ -449,172 +70,97 @@ def _complete_game(
)
-class TestComputePaceVsHLTB:
- """Tests for compute_pace_vs_hltb — 100 % branch coverage."""
+class TestWorstHours:
+ """Tests for _worst_hours (mirrors _stats worst-case selection)."""
- def test_no_completed_games_returns_none(self) -> None:
- incomplete = _gi(app_id=1, total_achievements=10, unlocked_achievements=0)
- assert compute_pace_vs_hltb([incomplete], {}) is None
+ def test_completionist_dominates(self) -> None:
+ """Test completionist dominates."""
+ game = _gi(completionist_hours=30.0)
+ assert _worst_hours(game, cache_hours=10.0, leisure=20.0) == 30.0
- def test_complete_but_zero_playtime_ignored(self) -> None:
- game = _complete_game(playtime_minutes=0)
- assert compute_pace_vs_hltb([game], {}) is None
+ def test_falls_back_to_cache_and_leisure_when_completionist_zero(self) -> None:
+ """Test falls back to cache and leisure when completionist zero."""
+ game = _gi(completionist_hours=0.0)
+ assert _worst_hours(game, cache_hours=15.0, leisure=8.0) == 15.0
- def test_no_rush_data_in_cache_returns_none(self) -> None:
- game = _complete_game(app_id=1)
- # cache has hours but no rush_hours
- cache = {
- 1: {
- "hours": 10.0,
- "polls": 5,
- "count_comp": 20,
- "rush_hours": -1,
- "leisure_100h": -1,
- "hltb_game_id": 0,
- }
- }
- assert compute_pace_vs_hltb([game], cache) is None
+ def test_minus_one_when_all_non_positive(self) -> None:
+ """Test minus one when all non positive."""
+ game = _gi(completionist_hours=0.0)
+ assert _worst_hours(game, cache_hours=-1.0, leisure=-1.0) == -1.0
- def test_rush_only_ratio_computed(self) -> None:
- """With rush but no leisure, ratio_vs_rush is computed, interpolation_t = -1."""
- game = _complete_game(app_id=1, playtime_minutes=600) # 10h actual
- cache = {
- 1: {
- "hours": 10.0,
- "polls": 5,
- "count_comp": 20,
- "rush_hours": 8.0,
- "leisure_100h": -1,
- "hltb_game_id": 0,
- }
- }
- result = compute_pace_vs_hltb([game], cache)
- assert result is not None
- assert result.calibration_count == 1
- assert result.ratio_vs_rush == round(10.0 / 8.0, 3)
- assert result.ratio_vs_leisure == -1.0
- assert result.interpolation_t == -1.0
- def test_rush_only_style_faster_than_rush_when_ratio_below_one(self) -> None:
- """Plays faster than rush (actual < rush) → style = faster_than_rush."""
- game = _complete_game(app_id=1, playtime_minutes=300) # 5h actual
- cache = {
- 1: {
- "hours": 10.0,
- "polls": 5,
- "count_comp": 20,
- "rush_hours": 8.0,
- "leisure_100h": -1,
- "hltb_game_id": 0,
- }
- }
- result = compute_pace_vs_hltb([game], cache)
- assert result is not None
- assert result.player_style == "faster_than_rush"
+class TestPassesDefaultConfidence:
+ """Tests for _passes_default_confidence."""
- def test_rush_only_style_unknown_when_ratio_at_or_above_one(self) -> None:
- """Without leisure data and ratio >= 1 → style = unknown."""
- game = _complete_game(app_id=1, playtime_minutes=600) # 10h
- cache = {
- 1: {
- "hours": 10.0,
- "polls": 5,
- "count_comp": 20,
- "rush_hours": 8.0,
- "leisure_100h": -1,
- "hltb_game_id": 0,
- }
- }
- result = compute_pace_vs_hltb([game], cache)
- assert result is not None
- assert result.player_style == "unknown"
+ def test_fail_low_comp_100(self) -> None:
+ """Test fail low comp 100."""
+ assert _passes_default_confidence(_wg(comp_100_count=2)) is False
- def test_both_rush_and_leisure_interpolation_computed(self) -> None:
- """With both rush + leisure, interpolation_t is computed."""
- # actual=10h, rush=8h, leisure=20h → t = (10-8)/(20-8) = 2/12 ≈ 0.167
- game = _complete_game(app_id=1, playtime_minutes=600)
- cache = {
- 1: {
- "hours": 10.0,
- "polls": 5,
- "count_comp": 20,
- "rush_hours": 8.0,
- "leisure_100h": 20.0,
- "hltb_game_id": 0,
- }
- }
- result = compute_pace_vs_hltb([game], cache)
- assert result is not None
- assert result.interpolation_t == round((10.0 - 8.0) / (20.0 - 8.0), 3)
- assert result.ratio_vs_leisure == round(10.0 / 20.0, 3)
- assert result.player_style == "rush_to_leisure"
+ def test_fail_low_count_comp(self) -> None:
+ """Test fail low count comp."""
+ assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=10)) is False
- def test_style_faster_than_rush_when_t_negative(self) -> None:
- """t < 0 means faster than rush."""
- game = _complete_game(app_id=1, playtime_minutes=300) # 5h actual
- cache = {
- 1: {
- "hours": 10.0,
- "polls": 5,
- "count_comp": 20,
- "rush_hours": 8.0,
- "leisure_100h": 20.0,
- "hltb_game_id": 0,
- }
- }
- result = compute_pace_vs_hltb([game], cache)
- assert result is not None
- assert result.interpolation_t < 0
- assert result.player_style == "faster_than_rush"
+ def test_pass_when_all_thresholds_met(self) -> None:
+ """Test pass when all thresholds met."""
+ assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=20)) is True
- def test_style_slower_than_leisure_when_t_above_one(self) -> None:
- """t > 1 means slower than leisure."""
- game = _complete_game(app_id=1, playtime_minutes=1500) # 25h actual
- cache = {
- 1: {
- "hours": 10.0,
- "polls": 5,
- "count_comp": 20,
- "rush_hours": 8.0,
- "leisure_100h": 20.0,
- "hltb_game_id": 0,
- }
- }
- result = compute_pace_vs_hltb([game], cache)
- assert result is not None
- assert result.interpolation_t > 1.0
- assert result.player_style == "slower_than_leisure"
- def test_interpolation_t_minus_one_when_leisure_not_greater_than_rush(self) -> None:
- """Edge case: leisure <= rush, can't divide, interpolation_t = -1."""
- game = _complete_game(app_id=1, playtime_minutes=600)
- # leisure == rush → denominator = 0
- cache = {
- 1: {
- "hours": 10.0,
- "polls": 5,
- "count_comp": 20,
- "rush_hours": 8.0,
- "leisure_100h": 8.0,
- "hltb_game_id": 0,
- }
- }
- result = compute_pace_vs_hltb([game], cache)
- assert result is not None
- assert result.interpolation_t == -1.0
+class TestHasAnyTime:
+ """Tests for _has_any_time."""
- def test_pace_vs_hltb_is_dataclass(self) -> None:
- """Return type is PaceVsHLTB."""
- game = _complete_game(app_id=1)
- cache = {
- 1: {
- "hours": 10.0,
- "polls": 5,
- "count_comp": 20,
- "rush_hours": 8.0,
- "leisure_100h": 20.0,
- "hltb_game_id": 0,
- }
- }
- result = compute_pace_vs_hltb([game], cache)
- assert isinstance(result, PaceVsHLTB)
+ def test_true_when_some_positive(self) -> None:
+ """Test true when some positive."""
+ assert _has_any_time(_wg(rush_hours=-1, leisure_hours=-1, worst_hours=5.0))
+
+ def test_false_when_all_non_positive(self) -> None:
+ """Test false when all non positive."""
+ game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1)
+ assert _has_any_time(game) is False
+
+
+class TestDefaultQualifying:
+ """Tests for _default_qualifying — each filter rejection branch."""
+
+ def test_rejects_low_confidence(self) -> None:
+ """Test rejects low confidence."""
+ assert _default_qualifying([_wg(count_comp=0)]) == []
+
+ def test_rejects_unplayable(self) -> None:
+ """Test rejects unplayable."""
+ game = _wg(protondb_tier="borked", protondb_trending_tier="borked")
+ assert _default_qualifying([game]) == []
+
+ def test_rejects_no_time(self) -> None:
+ """Test rejects no time."""
+ game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1)
+ assert _default_qualifying([game]) == []
+
+ def test_accepts_qualifying_game(self) -> None:
+ """Test accepts qualifying game."""
+ assert len(_default_qualifying([_wg()])) == 1
+
+
+class TestSumPositive:
+ """Tests for _sum_positive."""
+
+ def test_sums_only_positive(self) -> None:
+ """Test sums only positive."""
+ rows = [_wg(rush_hours=10.0), _wg(rush_hours=-1.0), _wg(rush_hours=5.5)]
+ assert _sum_positive(rows, "rush_hours") == 15.5
+
+ def test_empty(self) -> None:
+ """Test empty."""
+ assert _sum_positive([], "rush_hours") == 0.0
+
+
+class TestDefaultSummary:
+ """Tests for _default_summary."""
+
+ def test_totals(self) -> None:
+ """Test totals."""
+ rows = [_wg(rush_hours=10.0, leisure_hours=20.0, worst_hours=25.0)]
+ summary = _default_summary(rows)
+ assert summary.qualifying == 1
+ assert summary.rush_total == 10.0
+ assert summary.leisure_total == 20.0
+ assert summary.worst_total == 25.0
diff --git a/steam_backlog_enforcer/tests/test_web_dataset_build_games.py b/steam_backlog_enforcer/tests/test_web_dataset_build_games.py
new file mode 100644
index 0000000..23af982
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_web_dataset_build_games.py
@@ -0,0 +1,213 @@
+"""Tests for _web_dataset module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from unittest.mock import patch
+
+from steam_backlog_enforcer._web_dataset import (
+ HOURS_PER_DAY_PRESETS,
+ WebGame,
+ _build_games,
+ build_web_dataset,
+)
+from steam_backlog_enforcer.config import State
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_PKG = "steam_backlog_enforcer._web_dataset"
+
+
+def _gi(**over: object) -> GameInfo:
+ """Build a GameInfo with field overrides."""
+ base = GameInfo(
+ app_id=1,
+ name="G",
+ total_achievements=10,
+ unlocked_achievements=0,
+ playtime_minutes=60,
+ completionist_hours=20.0,
+ comp_100_count=5,
+ count_comp=20,
+ )
+ return replace(base, **over)
+
+
+def _wg(**over: object) -> WebGame:
+ """Build a WebGame with field overrides."""
+ base = WebGame(
+ app_id=1,
+ name="Game1",
+ completion_pct=0.0,
+ playtime_minutes=60,
+ rush_hours=10.0,
+ leisure_hours=20.0,
+ worst_hours=25.0,
+ count_comp=20,
+ comp_100_count=5,
+ hltb_game_id=0,
+ protondb_tier="gold",
+ protondb_trending_tier="gold",
+ protondb_score=0.8,
+ )
+ return replace(base, **over)
+
+
+def _complete_game(
+ app_id: int = 1,
+ playtime_minutes: int = 600,
+) -> GameInfo:
+ """Complete game (100 % achievements, has playtime)."""
+ return GameInfo(
+ app_id=app_id,
+ name=f"Done{app_id}",
+ total_achievements=10,
+ unlocked_achievements=10,
+ playtime_minutes=playtime_minutes,
+ completionist_hours=0.0,
+ comp_100_count=5,
+ count_comp=20,
+ )
+
+
+class TestBuildGames:
+ """Tests for _build_games (patches cache loaders, no file I/O)."""
+
+ def _run(
+ self,
+ games: list[GameInfo],
+ exclude: set[int],
+ raw: dict[int, dict[str, object]] | None = None,
+ protondb: dict[str, dict[str, object]] | None = None,
+ ) -> list[WebGame]:
+ """Test run."""
+ with (
+ patch(f"{_PKG}._read_raw_cache", return_value=raw or {}),
+ patch(f"{_PKG}._load_cache", return_value=protondb or {}),
+ ):
+ return _build_games(games, exclude)
+
+ def test_skips_complete_games(self) -> None:
+ """Test skips complete games."""
+ rows = self._run(
+ [_gi(app_id=1, total_achievements=5, unlocked_achievements=5)], set()
+ )
+ assert rows == []
+
+ def test_skips_excluded_games(self) -> None:
+ """Test skips excluded games."""
+ assert self._run([_gi(app_id=1)], {1}) == []
+
+ def test_uses_cache_entry_when_present(self) -> None:
+ """Test uses cache entry when present."""
+ raw = {
+ 1: {
+ "hours": 18.0,
+ "polls": 7,
+ "count_comp": 30,
+ "rush_hours": 9.0,
+ "leisure_100h": 22.0,
+ "hltb_game_id": 555,
+ }
+ }
+ proton = {"1": {"tier": "platinum", "trending_tier": "gold", "score": 0.9}}
+ rows = self._run([_gi(app_id=1, completionist_hours=0.0)], set(), raw, proton)
+ assert len(rows) == 1
+ row = rows[0]
+ assert row.rush_hours == 9.0
+ assert row.leisure_hours == 22.0
+ assert row.worst_hours == 22.0 # max(cache 18, leisure 22)
+ assert row.count_comp == 30
+ assert row.comp_100_count == 7
+ assert row.hltb_game_id == 555
+ assert row.protondb_tier == "platinum"
+ assert row.protondb_trending_tier == "gold"
+
+ def test_defaults_when_no_cache_entries(self) -> None:
+ """Test defaults when no cache entries."""
+ rows = self._run([_gi(app_id=1, completionist_hours=12.0)], set())
+ assert len(rows) == 1
+ row = rows[0]
+ assert row.rush_hours == -1
+ assert row.leisure_hours == -1
+ assert row.worst_hours == 12.0 # completionist only
+ assert row.protondb_tier == "" # no protondb entry
+
+
+class TestBuildWebDataset:
+ """Tests for build_web_dataset (top-level projection)."""
+
+ def test_no_snapshot_returns_empty_games(self) -> None:
+ """Test no snapshot returns empty games."""
+ with (
+ patch(f"{_PKG}.load_snapshot", return_value=None),
+ patch(f"{_PKG}._read_raw_cache", return_value={}),
+ patch(f"{_PKG}._load_cache", return_value={}),
+ ):
+ ds = build_web_dataset(State())
+ assert ds.games == []
+ assert ds.state.games_done == 0
+ assert ds.default_summary.qualifying == 0
+ assert ds.defaults.hours_per_day_presets == list(HOURS_PER_DAY_PRESETS)
+
+ def test_excludes_current_app_id(self) -> None:
+ """Test excludes current app id."""
+ snapshot = [_gi(app_id=1).to_snapshot(), _gi(app_id=2).to_snapshot()]
+ raw = {
+ aid: {
+ "hours": -1,
+ "polls": 5,
+ "count_comp": 20,
+ "rush_hours": 10.0,
+ "leisure_100h": 25.0,
+ "hltb_game_id": 0,
+ }
+ for aid in (1, 2)
+ }
+ proton = {str(a): {"tier": "gold", "trending_tier": "gold"} for a in (1, 2)}
+ with (
+ patch(f"{_PKG}.load_snapshot", return_value=snapshot),
+ patch(f"{_PKG}._read_raw_cache", return_value=raw),
+ patch(f"{_PKG}._load_cache", return_value=proton),
+ ):
+ ds = build_web_dataset(State(current_app_id=1))
+ assert [g.app_id for g in ds.games] == [2]
+
+ def test_parity_mini_oracle(self) -> None:
+ """A small hand-checked dataset reproduces qualifying + totals."""
+ # g1 qualifies; g2 fails confidence; g3 is complete (excluded).
+ snapshot = [
+ _gi(app_id=1, completionist_hours=0.0).to_snapshot(),
+ _gi(app_id=2, completionist_hours=0.0).to_snapshot(),
+ _gi(app_id=3, total_achievements=5, unlocked_achievements=5).to_snapshot(),
+ ]
+ raw = {
+ 1: {
+ "hours": -1,
+ "polls": 5,
+ "count_comp": 20,
+ "rush_hours": 10.0,
+ "leisure_100h": 25.0,
+ "hltb_game_id": 0,
+ },
+ 2: {
+ "hours": -1,
+ "polls": 5,
+ "count_comp": 0, # fails count_comp threshold
+ "rush_hours": 10.0,
+ "leisure_100h": 25.0,
+ "hltb_game_id": 0,
+ },
+ }
+ proton = {"1": {"tier": "gold", "trending_tier": "gold"}}
+ with (
+ patch(f"{_PKG}.load_snapshot", return_value=snapshot),
+ patch(f"{_PKG}._read_raw_cache", return_value=raw),
+ patch(f"{_PKG}._load_cache", return_value=proton),
+ ):
+ ds = build_web_dataset(State())
+ assert ds.state.games_done == 1 # g3 complete
+ assert len(ds.games) == 2 # g1 + g2 candidates, g3 excluded
+ assert ds.default_summary.qualifying == 1 # only g1
+ assert ds.default_summary.rush_total == 10.0
+ assert ds.default_summary.leisure_total == 25.0
+ assert ds.default_summary.worst_total == 25.0
diff --git a/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b.py b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b.py
new file mode 100644
index 0000000..88b590c
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b.py
@@ -0,0 +1,149 @@
+"""Tests for _web_dataset module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+
+from steam_backlog_enforcer._web_dataset import (
+ WebGame,
+ compute_pace_vs_hltb,
+)
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_PKG = "steam_backlog_enforcer._web_dataset"
+
+
+def _gi(**over: object) -> GameInfo:
+ """Build a GameInfo with field overrides."""
+ base = GameInfo(
+ app_id=1,
+ name="G",
+ total_achievements=10,
+ unlocked_achievements=0,
+ playtime_minutes=60,
+ completionist_hours=20.0,
+ comp_100_count=5,
+ count_comp=20,
+ )
+ return replace(base, **over)
+
+
+def _wg(**over: object) -> WebGame:
+ """Build a WebGame with field overrides."""
+ base = WebGame(
+ app_id=1,
+ name="Game1",
+ completion_pct=0.0,
+ playtime_minutes=60,
+ rush_hours=10.0,
+ leisure_hours=20.0,
+ worst_hours=25.0,
+ count_comp=20,
+ comp_100_count=5,
+ hltb_game_id=0,
+ protondb_tier="gold",
+ protondb_trending_tier="gold",
+ protondb_score=0.8,
+ )
+ return replace(base, **over)
+
+
+def _complete_game(
+ app_id: int = 1,
+ playtime_minutes: int = 600,
+) -> GameInfo:
+ """Complete game (100 % achievements, has playtime)."""
+ return GameInfo(
+ app_id=app_id,
+ name=f"Done{app_id}",
+ total_achievements=10,
+ unlocked_achievements=10,
+ playtime_minutes=playtime_minutes,
+ completionist_hours=0.0,
+ comp_100_count=5,
+ count_comp=20,
+ )
+
+
+class TestComputePaceVsHLTB:
+ """Tests for compute_pace_vs_hltb — 100 % branch coverage."""
+
+ def test_no_completed_games_returns_none(self) -> None:
+ """Test no completed games returns none."""
+ incomplete = _gi(app_id=1, total_achievements=10, unlocked_achievements=0)
+ assert compute_pace_vs_hltb([incomplete], {}) is None
+
+ def test_complete_but_zero_playtime_ignored(self) -> None:
+ """Test complete but zero playtime ignored."""
+ game = _complete_game(playtime_minutes=0)
+ assert compute_pace_vs_hltb([game], {}) is None
+
+ def test_no_rush_data_in_cache_returns_none(self) -> None:
+ """Test no rush data in cache returns none."""
+ game = _complete_game(app_id=1)
+ # cache has hours but no rush_hours
+ cache = {
+ 1: {
+ "hours": 10.0,
+ "polls": 5,
+ "count_comp": 20,
+ "rush_hours": -1,
+ "leisure_100h": -1,
+ "hltb_game_id": 0,
+ }
+ }
+ assert compute_pace_vs_hltb([game], cache) is None
+
+ def test_rush_only_ratio_computed(self) -> None:
+ """With rush but no leisure, ratio_vs_rush is computed, interpolation_t = -1."""
+ game = _complete_game(app_id=1, playtime_minutes=600) # 10h actual
+ cache = {
+ 1: {
+ "hours": 10.0,
+ "polls": 5,
+ "count_comp": 20,
+ "rush_hours": 8.0,
+ "leisure_100h": -1,
+ "hltb_game_id": 0,
+ }
+ }
+ result = compute_pace_vs_hltb([game], cache)
+ assert result is not None
+ assert result.calibration_count == 1
+ assert result.ratio_vs_rush == round(10.0 / 8.0, 3)
+ assert result.ratio_vs_leisure == -1.0
+ assert result.interpolation_t == -1.0
+
+ def test_rush_only_style_faster_than_rush_when_ratio_below_one(self) -> None:
+ """Plays faster than rush (actual < rush) → style = faster_than_rush."""
+ game = _complete_game(app_id=1, playtime_minutes=300) # 5h actual
+ cache = {
+ 1: {
+ "hours": 10.0,
+ "polls": 5,
+ "count_comp": 20,
+ "rush_hours": 8.0,
+ "leisure_100h": -1,
+ "hltb_game_id": 0,
+ }
+ }
+ result = compute_pace_vs_hltb([game], cache)
+ assert result is not None
+ assert result.player_style == "faster_than_rush"
+
+ def test_rush_only_style_unknown_when_ratio_at_or_above_one(self) -> None:
+ """Without leisure data and ratio >= 1 → style = unknown."""
+ game = _complete_game(app_id=1, playtime_minutes=600) # 10h
+ cache = {
+ 1: {
+ "hours": 10.0,
+ "polls": 5,
+ "count_comp": 20,
+ "rush_hours": 8.0,
+ "leisure_100h": -1,
+ "hltb_game_id": 0,
+ }
+ }
+ result = compute_pace_vs_hltb([game], cache)
+ assert result is not None
+ assert result.player_style == "unknown"
diff --git a/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b_both_rush_and_leisure_interpolation_comp.py b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b_both_rush_and_leisure_interpolation_comp.py
new file mode 100644
index 0000000..073592b
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b_both_rush_and_leisure_interpolation_comp.py
@@ -0,0 +1,160 @@
+"""Tests for _web_dataset module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+
+from steam_backlog_enforcer._web_dataset import (
+ PaceVsHLTB,
+ WebGame,
+ compute_pace_vs_hltb,
+)
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_PKG = "steam_backlog_enforcer._web_dataset"
+
+
+def _gi(**over: object) -> GameInfo:
+ """Build a GameInfo with field overrides."""
+ base = GameInfo(
+ app_id=1,
+ name="G",
+ total_achievements=10,
+ unlocked_achievements=0,
+ playtime_minutes=60,
+ completionist_hours=20.0,
+ comp_100_count=5,
+ count_comp=20,
+ )
+ return replace(base, **over)
+
+
+def _wg(**over: object) -> WebGame:
+ """Build a WebGame with field overrides."""
+ base = WebGame(
+ app_id=1,
+ name="Game1",
+ completion_pct=0.0,
+ playtime_minutes=60,
+ rush_hours=10.0,
+ leisure_hours=20.0,
+ worst_hours=25.0,
+ count_comp=20,
+ comp_100_count=5,
+ hltb_game_id=0,
+ protondb_tier="gold",
+ protondb_trending_tier="gold",
+ protondb_score=0.8,
+ )
+ return replace(base, **over)
+
+
+def _complete_game(
+ app_id: int = 1,
+ playtime_minutes: int = 600,
+) -> GameInfo:
+ """Complete game (100 % achievements, has playtime)."""
+ return GameInfo(
+ app_id=app_id,
+ name=f"Done{app_id}",
+ total_achievements=10,
+ unlocked_achievements=10,
+ playtime_minutes=playtime_minutes,
+ completionist_hours=0.0,
+ comp_100_count=5,
+ count_comp=20,
+ )
+
+
+class TestComputePaceVsHLTBGroup2:
+ """Tests for compute_pace_vs_hltb — 100 % branch coverage."""
+
+ def test_both_rush_and_leisure_interpolation_computed(self) -> None:
+ """With both rush + leisure, interpolation_t is computed."""
+ # actual=10h, rush=8h, leisure=20h → t = (10-8)/(20-8) = 2/12 ≈ 0.167
+ game = _complete_game(app_id=1, playtime_minutes=600)
+ cache = {
+ 1: {
+ "hours": 10.0,
+ "polls": 5,
+ "count_comp": 20,
+ "rush_hours": 8.0,
+ "leisure_100h": 20.0,
+ "hltb_game_id": 0,
+ }
+ }
+ result = compute_pace_vs_hltb([game], cache)
+ assert result is not None
+ assert result.interpolation_t == round((10.0 - 8.0) / (20.0 - 8.0), 3)
+ assert result.ratio_vs_leisure == round(10.0 / 20.0, 3)
+ assert result.player_style == "rush_to_leisure"
+
+ def test_style_faster_than_rush_when_t_negative(self) -> None:
+ """t < 0 means faster than rush."""
+ game = _complete_game(app_id=1, playtime_minutes=300) # 5h actual
+ cache = {
+ 1: {
+ "hours": 10.0,
+ "polls": 5,
+ "count_comp": 20,
+ "rush_hours": 8.0,
+ "leisure_100h": 20.0,
+ "hltb_game_id": 0,
+ }
+ }
+ result = compute_pace_vs_hltb([game], cache)
+ assert result is not None
+ assert result.interpolation_t < 0
+ assert result.player_style == "faster_than_rush"
+
+ def test_style_slower_than_leisure_when_t_above_one(self) -> None:
+ """t > 1 means slower than leisure."""
+ game = _complete_game(app_id=1, playtime_minutes=1500) # 25h actual
+ cache = {
+ 1: {
+ "hours": 10.0,
+ "polls": 5,
+ "count_comp": 20,
+ "rush_hours": 8.0,
+ "leisure_100h": 20.0,
+ "hltb_game_id": 0,
+ }
+ }
+ result = compute_pace_vs_hltb([game], cache)
+ assert result is not None
+ assert result.interpolation_t > 1.0
+ assert result.player_style == "slower_than_leisure"
+
+ def test_interpolation_t_minus_one_when_leisure_not_greater_than_rush(self) -> None:
+ """Edge case: leisure <= rush, can't divide, interpolation_t = -1."""
+ game = _complete_game(app_id=1, playtime_minutes=600)
+ # leisure == rush → denominator = 0
+ cache = {
+ 1: {
+ "hours": 10.0,
+ "polls": 5,
+ "count_comp": 20,
+ "rush_hours": 8.0,
+ "leisure_100h": 8.0,
+ "hltb_game_id": 0,
+ }
+ }
+ result = compute_pace_vs_hltb([game], cache)
+ assert result is not None
+ assert result.interpolation_t == -1.0
+
+ def test_pace_vs_hltb_is_dataclass(self) -> None:
+ """Return type is PaceVsHLTB."""
+ game = _complete_game(app_id=1)
+ cache = {
+ 1: {
+ "hours": 10.0,
+ "polls": 5,
+ "count_comp": 20,
+ "rush_hours": 8.0,
+ "leisure_100h": 20.0,
+ "hltb_game_id": 0,
+ }
+ }
+ result = compute_pace_vs_hltb([game], cache)
+ assert isinstance(result, PaceVsHLTB)
diff --git a/steam_backlog_enforcer/tests/test_web_dataset_count_complete_since_start.py b/steam_backlog_enforcer/tests/test_web_dataset_count_complete_since_start.py
new file mode 100644
index 0000000..4e68c66
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_web_dataset_count_complete_since_start.py
@@ -0,0 +1,207 @@
+"""Tests for _web_dataset module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from datetime import datetime, timedelta, timezone
+
+from steam_backlog_enforcer._web_dataset import (
+ WebGame,
+ _state_info,
+ count_complete_since_start,
+)
+from steam_backlog_enforcer.config import State
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_PKG = "steam_backlog_enforcer._web_dataset"
+
+
+def _gi(**over: object) -> GameInfo:
+ """Build a GameInfo with field overrides."""
+ base = GameInfo(
+ app_id=1,
+ name="G",
+ total_achievements=10,
+ unlocked_achievements=0,
+ playtime_minutes=60,
+ completionist_hours=20.0,
+ comp_100_count=5,
+ count_comp=20,
+ )
+ return replace(base, **over)
+
+
+def _wg(**over: object) -> WebGame:
+ """Build a WebGame with field overrides."""
+ base = WebGame(
+ app_id=1,
+ name="Game1",
+ completion_pct=0.0,
+ playtime_minutes=60,
+ rush_hours=10.0,
+ leisure_hours=20.0,
+ worst_hours=25.0,
+ count_comp=20,
+ comp_100_count=5,
+ hltb_game_id=0,
+ protondb_tier="gold",
+ protondb_trending_tier="gold",
+ protondb_score=0.8,
+ )
+ return replace(base, **over)
+
+
+def _complete_game(
+ app_id: int = 1,
+ playtime_minutes: int = 600,
+) -> GameInfo:
+ """Complete game (100 % achievements, has playtime)."""
+ return GameInfo(
+ app_id=app_id,
+ name=f"Done{app_id}",
+ total_achievements=10,
+ unlocked_achievements=10,
+ playtime_minutes=playtime_minutes,
+ completionist_hours=0.0,
+ comp_100_count=5,
+ count_comp=20,
+ )
+
+
+class TestCountCompleteSinceStart:
+ """Tests for count_complete_since_start."""
+
+ def _ach(self, ts: int, *, achieved: bool = True) -> object:
+ """Test ach."""
+ from steam_backlog_enforcer.steam_api import AchievementInfo
+
+ return AchievementInfo(
+ api_name="A", display_name="A", achieved=achieved, unlock_time=ts
+ )
+
+ def _complete_game(self, app_id: int, unlock_ts: int) -> GameInfo:
+ """Test complete game."""
+ achs = [self._ach(unlock_ts)] * 5
+ return _gi(
+ app_id=app_id,
+ total_achievements=5,
+ unlocked_achievements=5,
+ achievements=achs,
+ )
+
+ def test_empty_started_at_returns_zero(self) -> None:
+ """Test empty started at returns zero."""
+ games = [self._complete_game(1, 1_000_000)]
+ assert count_complete_since_start(games, "") == 0
+
+ def test_invalid_started_at_returns_zero(self) -> None:
+ """Test invalid started at returns zero."""
+ games = [self._complete_game(1, 1_000_000)]
+ assert count_complete_since_start(games, "not-a-date") == 0
+
+ def test_counts_game_completed_after_start(self) -> None:
+ """Test counts game completed after start."""
+ started = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp())
+ games = [self._complete_game(1, after_ts)]
+ assert count_complete_since_start(games, started.isoformat()) == 1
+
+ def test_excludes_game_completed_before_start(self) -> None:
+ """Test excludes game completed before start."""
+ started = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp())
+ games = [self._complete_game(1, before_ts)]
+ assert count_complete_since_start(games, started.isoformat()) == 0
+
+ def test_excludes_incomplete_game(self) -> None:
+ """Test excludes incomplete game."""
+ started = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp())
+ incomplete = _gi(
+ app_id=1,
+ total_achievements=5,
+ unlocked_achievements=3,
+ achievements=[self._ach(after_ts)] * 3,
+ )
+ assert count_complete_since_start([incomplete], started.isoformat()) == 0
+
+ def test_excludes_game_with_no_achievement_timestamps(self) -> None:
+ """Complete game with unlock_time=0 on all achievements is excluded."""
+ started = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ no_ts = _gi(
+ app_id=1,
+ total_achievements=5,
+ unlocked_achievements=5,
+ achievements=[self._ach(0)] * 5,
+ )
+ assert count_complete_since_start([no_ts], started.isoformat()) == 0
+
+ def test_mixed_games_counts_only_post_start(self) -> None:
+ """Test mixed games counts only post start."""
+ started = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp())
+ before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp())
+ games = [
+ self._complete_game(1, after_ts),
+ self._complete_game(2, before_ts),
+ self._complete_game(3, after_ts),
+ ]
+ assert count_complete_since_start(games, started.isoformat()) == 2
+
+ def test_uses_max_unlock_time_across_achievements(self) -> None:
+ """Game counts if its LAST achievement was unlocked after start."""
+ started = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ before_ts = int(datetime(2025, 12, 1, tzinfo=timezone.utc).timestamp())
+ after_ts = int(datetime(2026, 2, 1, tzinfo=timezone.utc).timestamp())
+ # Mix of before/after timestamps — max is after start, so should count
+ achs = [self._ach(before_ts)] * 4 + [self._ach(after_ts)]
+ game = _gi(
+ app_id=1, total_achievements=5, unlocked_achievements=5, achievements=achs
+ )
+ assert count_complete_since_start([game], started.isoformat()) == 1
+
+
+class TestStateInfo:
+ """Tests for _state_info pace calculation."""
+
+ def test_no_start_date(self) -> None:
+ """Test no start date."""
+ info = _state_info(State(), games_done=5, games_done_since_start=5)
+ assert info.days_elapsed == 0
+ assert info.pace_games_per_day == 0.0
+ assert info.games_done == 5
+ assert info.games_done_since_start == 5
+
+ def test_invalid_start_date(self) -> None:
+ """Test invalid start date."""
+ info = _state_info(
+ State(enforcement_started_at="not-a-date"),
+ games_done=5,
+ games_done_since_start=5,
+ )
+ assert info.days_elapsed == 0
+ assert info.pace_games_per_day == 0.0
+
+ def test_valid_start_with_games(self) -> None:
+ """Test valid start with games."""
+ started = datetime.now(timezone.utc) - timedelta(days=50)
+ info = _state_info(
+ State(enforcement_started_at=started.isoformat()),
+ games_done=12,
+ games_done_since_start=10,
+ )
+ assert info.days_elapsed >= 49
+ assert info.pace_games_per_day > 0.0
+ assert info.games_done == 12
+ assert info.games_done_since_start == 10
+
+ def test_valid_start_zero_since_start_keeps_zero_pace(self) -> None:
+ """games_done_since_start=0 → pace stays 0 even if total games_done > 0."""
+ started = datetime.now(timezone.utc) - timedelta(days=50)
+ info = _state_info(
+ State(enforcement_started_at=started.isoformat()),
+ games_done=5,
+ games_done_since_start=0,
+ )
+ assert info.days_elapsed >= 49
+ assert info.pace_games_per_day == 0.0
diff --git a/steam_backlog_enforcer/tests/test_web_dataset_dataset_to_payload.py b/steam_backlog_enforcer/tests/test_web_dataset_dataset_to_payload.py
new file mode 100644
index 0000000..45b1412
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_web_dataset_dataset_to_payload.py
@@ -0,0 +1,91 @@
+"""Tests for _web_dataset module — 100% branch coverage."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from unittest.mock import patch
+
+from steam_backlog_enforcer._web_dataset import (
+ WebGame,
+ build_web_dataset,
+ dataset_to_payload,
+)
+from steam_backlog_enforcer.config import State
+from steam_backlog_enforcer.steam_api import GameInfo
+
+_PKG = "steam_backlog_enforcer._web_dataset"
+
+
+def _gi(**over: object) -> GameInfo:
+ """Build a GameInfo with field overrides."""
+ base = GameInfo(
+ app_id=1,
+ name="G",
+ total_achievements=10,
+ unlocked_achievements=0,
+ playtime_minutes=60,
+ completionist_hours=20.0,
+ comp_100_count=5,
+ count_comp=20,
+ )
+ return replace(base, **over)
+
+
+def _wg(**over: object) -> WebGame:
+ """Build a WebGame with field overrides."""
+ base = WebGame(
+ app_id=1,
+ name="Game1",
+ completion_pct=0.0,
+ playtime_minutes=60,
+ rush_hours=10.0,
+ leisure_hours=20.0,
+ worst_hours=25.0,
+ count_comp=20,
+ comp_100_count=5,
+ hltb_game_id=0,
+ protondb_tier="gold",
+ protondb_trending_tier="gold",
+ protondb_score=0.8,
+ )
+ return replace(base, **over)
+
+
+def _complete_game(
+ app_id: int = 1,
+ playtime_minutes: int = 600,
+) -> GameInfo:
+ """Complete game (100 % achievements, has playtime)."""
+ return GameInfo(
+ app_id=app_id,
+ name=f"Done{app_id}",
+ total_achievements=10,
+ unlocked_achievements=10,
+ playtime_minutes=playtime_minutes,
+ completionist_hours=0.0,
+ comp_100_count=5,
+ count_comp=20,
+ )
+
+
+class TestDatasetToPayload:
+ """Tests for dataset_to_payload."""
+
+ def test_serializes_to_dict(self) -> None:
+ """Test serializes to dict."""
+ with (
+ patch(f"{_PKG}.load_snapshot", return_value=None),
+ patch(f"{_PKG}._read_raw_cache", return_value={}),
+ patch(f"{_PKG}._load_cache", return_value={}),
+ ):
+ payload = dataset_to_payload(build_web_dataset(State()))
+ assert set(payload) == {
+ "games",
+ "state",
+ "defaults",
+ "default_summary",
+ "pace_vs_hltb",
+ "generated_at",
+ }
+ assert isinstance(payload["games"], list)
+ assert isinstance(payload["state"], dict)
diff --git a/steam_backlog_enforcer/tests/test_whitelist.py b/steam_backlog_enforcer/tests/test_whitelist.py
index f3444e7..8b50c47 100644
--- a/steam_backlog_enforcer/tests/test_whitelist.py
+++ b/steam_backlog_enforcer/tests/test_whitelist.py
@@ -5,24 +5,13 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
-import pytest
-
from steam_backlog_enforcer._whitelist import (
- _append_audit_log,
- _immutable_flag_is,
- _load_approved,
- _save_approved,
_shannon_entropy,
_try_set_immutable,
- add_pending_exception,
- get_approved_exception_ids,
- lock_enforcement_files,
- unlock_for_write,
validate_reason,
)
if TYPE_CHECKING:
- import array
from pathlib import Path
# ──────────────────────────────────────────────────────────────
@@ -37,78 +26,122 @@ _VALID_REASON = "I need this game installed for a work presentation this week."
# ──────────────────────────────────────────────────────────────
-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
# ──────────────────────────────────────────────────────────────
+# ──────────────────────────────────────────────────────────────
+# Persistence helpers (_load_approved, _save_approved)
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# Audit log
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# add_pending_exception
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# get_approved_exception_ids
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# Extra coverage for validate_reason branches 94 & 106
+# ──────────────────────────────────────────────────────────────
+
+
+class TestShannonEntropy:
+ """Tests for Shannon Entropy."""
+
+ def test_empty_string(self) -> None:
+ """Test empty string."""
+ assert _shannon_entropy("") == 0.0
+
+ def test_all_whitespace(self) -> None:
+ """Test all whitespace."""
+ assert _shannon_entropy(" ") == 0.0
+
+ def test_single_char(self) -> None:
+ # one unique char → entropy = 0
+ """Test single char."""
+ assert _shannon_entropy("aaaa") == 0.0
+
+ def test_high_entropy(self) -> None:
+ # natural English sentence has decent entropy
+ """Test high entropy."""
+ assert _shannon_entropy("The quick brown fox jumps") > 3.0
+
+
+class TestValidateReason:
+ """Tests for Validate Reason."""
+
+ def test_valid_reason_returns_none(self) -> None:
+ """Test valid reason returns none."""
+ assert validate_reason(_VALID_REASON) is None
+
+ def test_too_short(self) -> None:
+ """Test too short."""
+ 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
+ """Test too few 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
+ """Test low entropy rejected."""
+ 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:
+ """Test char run rejected."""
+ 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
+ """Test alternating pattern rejected."""
+ err = validate_reason("abababababababababababababababababababababab")
+ assert err is not None
+ assert "repetitive" in err or "random" in err or err is not None
+
+
class TestTrySetImmutable:
+ """Tests for Try Set Immutable."""
+
def test_file_does_not_exist(self, tmp_path: Path) -> None:
# Should silently do nothing when the file doesn't exist
+ """Test file does not exist."""
_try_set_immutable(tmp_path / "nonexistent.txt", immutable=True)
def test_chattr_not_available(self, tmp_path: Path) -> None:
+ """Test chattr not available."""
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:
+ """Test chattr called set."""
target = tmp_path / "file.txt"
target.write_text("data", encoding="utf-8")
fake_chattr = tmp_path / "chattr"
@@ -122,6 +155,7 @@ class TestTrySetImmutable:
assert "+i" in args
def test_chattr_called_clear(self, tmp_path: Path) -> None:
+ """Test chattr called clear."""
target = tmp_path / "file.txt"
target.write_text("data", encoding="utf-8")
fake_chattr = tmp_path / "chattr"
@@ -142,6 +176,7 @@ class TestTrySetImmutable:
def test_skips_chattr_when_flag_already_correct(self, tmp_path: Path) -> None:
# The hot path: lock_enforcement_files runs every loop iteration, and
# the flag is already what it should be, so nothing should be spawned.
+ """Test skips chattr when flag already correct."""
target = tmp_path / "file.txt"
target.write_text("data", encoding="utf-8")
with (
@@ -155,258 +190,3 @@ class TestTrySetImmutable:
_try_set_immutable(target, immutable=True)
mock_run.assert_not_called()
mock_which.assert_not_called()
-
-
-class TestImmutableFlagIs:
- def test_reads_clear_flag_on_real_file(self, tmp_path: Path) -> None:
- target = tmp_path / "file.txt"
- target.write_text("data", encoding="utf-8")
- assert _immutable_flag_is(target, immutable=False) is True
- assert _immutable_flag_is(target, immutable=True) is False
-
- def test_fails_closed_when_ioctl_refuses(self, tmp_path: Path) -> None:
- # Filesystems without flag support raise OSError; we must report False
- # so the caller still shells out rather than silently skipping.
- target = tmp_path / "file.txt"
- target.write_text("data", encoding="utf-8")
- with patch("fcntl.ioctl", side_effect=OSError("unsupported")):
- assert _immutable_flag_is(target, immutable=False) is False
- assert _immutable_flag_is(target, immutable=True) is False
-
- def test_fails_closed_on_value_error(self, tmp_path: Path) -> None:
- target = tmp_path / "file.txt"
- target.write_text("data", encoding="utf-8")
- with patch("fcntl.ioctl", side_effect=ValueError("bad buffer")):
- assert _immutable_flag_is(target, immutable=True) is False
-
- def test_fails_closed_when_file_cannot_be_opened(self, tmp_path: Path) -> None:
- assert _immutable_flag_is(tmp_path / "missing.txt", immutable=True) is False
-
- def test_reports_set_flag(self, tmp_path: Path) -> None:
- target = tmp_path / "file.txt"
- target.write_text("data", encoding="utf-8")
-
- def _fake_ioctl(_fd: int, _req: int, buf: array.array[int]) -> int:
- buf[0] = 0x00000010 # FS_IMMUTABLE_FL
- return 0
-
- with patch("fcntl.ioctl", side_effect=_fake_ioctl):
- assert _immutable_flag_is(target, immutable=True) is True
- assert _immutable_flag_is(target, immutable=False) is False
-
- 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(
- "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"),
- # Pretend the file really is immutable; a fresh tmp file is not,
- # and unlocking an already-mutable file is now a no-op.
- patch(
- "steam_backlog_enforcer._whitelist._immutable_flag_is",
- return_value=False,
- ),
- patch("subprocess.run") as mock_run,
- ):
- unlock_for_write(target)
- args = mock_run.call_args[0][0]
- assert "-i" in args
-
- def test_unlock_for_write_skips_already_mutable_file(self, tmp_path: Path) -> None:
- target = tmp_path / "file.txt"
- target.write_text("data", encoding="utf-8")
- with patch("subprocess.run") as mock_run:
- unlock_for_write(target)
- mock_run.assert_not_called()
-
-
-# ──────────────────────────────────────────────────────────────
-# Persistence helpers (_load_approved, _save_approved)
-# ──────────────────────────────────────────────────────────────
-
-
-class TestPersistence:
- 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(
- "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(
- "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(
- "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(
- "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 "immediately" in msg.lower()
- approved = _load_approved()
- assert len(approved) == 1
- assert int(approved[0]["app_id"]) == 440
- # Now active right away, no cooldown.
- assert 440 in get_approved_exception_ids()
-
- 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)
-
-
-# ──────────────────────────────────────────────────────────────
-# 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})
-
-
-# ──────────────────────────────────────────────────────────────
-# 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
diff --git a/steam_backlog_enforcer/tests/test_whitelist_add_pending_exception.py b/steam_backlog_enforcer/tests/test_whitelist_add_pending_exception.py
new file mode 100644
index 0000000..e382dae
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_whitelist_add_pending_exception.py
@@ -0,0 +1,137 @@
+"""Tests for _whitelist.py: time-locked exceptions, reason validation, chattr."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+
+from steam_backlog_enforcer._whitelist import (
+ _load_approved,
+ _save_approved,
+ add_pending_exception,
+ get_approved_exception_ids,
+ validate_reason,
+)
+
+# ──────────────────────────────────────────────────────────────
+# Helpers
+# ──────────────────────────────────────────────────────────────
+
+_VALID_REASON = "I need this game installed for a work presentation this week."
+
+
+# ──────────────────────────────────────────────────────────────
+# Shannon entropy
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# validate_reason
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# chattr helpers
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# Persistence helpers (_load_approved, _save_approved)
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# Audit log
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# add_pending_exception
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# get_approved_exception_ids
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# Extra coverage for validate_reason branches 94 & 106
+# ──────────────────────────────────────────────────────────────
+
+
+class TestAddPendingException:
+ """Tests for Add Pending Exception."""
+
+ def test_add_new_exception(self) -> None:
+ """Test add new exception."""
+ with patch("shutil.which", return_value=None):
+ msg = add_pending_exception(440, _VALID_REASON)
+ assert "440" in msg
+ assert "immediately" in msg.lower()
+ approved = _load_approved()
+ assert len(approved) == 1
+ assert int(approved[0]["app_id"]) == 440
+ # Now active right away, no cooldown.
+ assert 440 in get_approved_exception_ids()
+
+ def test_invalid_reason_raises(self) -> None:
+ """Test invalid reason raises."""
+ with pytest.raises(
+ ValueError, match=r"short|words|entropy|repeated|repetitive"
+ ):
+ add_pending_exception(440, "too short")
+
+ def test_already_approved_raises(self) -> None:
+ """Test already approved raises."""
+ 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)
+
+
+class TestGetApprovedExceptionIds:
+ """Tests for Get Approved Exception Ids."""
+
+ def test_empty(self) -> None:
+ """Test empty."""
+ result = get_approved_exception_ids()
+ assert result == frozenset()
+
+ def test_populated(self) -> None:
+ """Test populated."""
+ 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})
+
+
+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
+ """Test low entropy multi word."""
+ 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
+ """Test alternating pattern multi word."""
+ 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
diff --git a/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py b/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py
new file mode 100644
index 0000000..2313e96
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py
@@ -0,0 +1,225 @@
+"""Tests for _whitelist.py: time-locked exceptions, reason validation, chattr."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from unittest.mock import patch
+
+from steam_backlog_enforcer._whitelist import (
+ _immutable_flag_is,
+ _load_approved,
+ _save_approved,
+ _try_set_immutable,
+ lock_enforcement_files,
+ unlock_for_write,
+)
+
+if TYPE_CHECKING:
+ import array
+ from pathlib import Path
+
+# ──────────────────────────────────────────────────────────────
+# Helpers
+# ──────────────────────────────────────────────────────────────
+
+_VALID_REASON = "I need this game installed for a work presentation this week."
+
+
+# ──────────────────────────────────────────────────────────────
+# Shannon entropy
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# validate_reason
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# chattr helpers
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# Persistence helpers (_load_approved, _save_approved)
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# Audit log
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# add_pending_exception
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# get_approved_exception_ids
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# Extra coverage for validate_reason branches 94 & 106
+# ──────────────────────────────────────────────────────────────
+
+
+class TestImmutableFlagIs:
+ """Tests for Immutable Flag Is."""
+
+ def test_reads_clear_flag_on_real_file(self, tmp_path: Path) -> None:
+ """Test reads clear flag on real file."""
+ target = tmp_path / "file.txt"
+ target.write_text("data", encoding="utf-8")
+ assert _immutable_flag_is(target, immutable=False) is True
+ assert _immutable_flag_is(target, immutable=True) is False
+
+ def test_fails_closed_when_ioctl_refuses(self, tmp_path: Path) -> None:
+ # Filesystems without flag support raise OSError; we must report False
+ # so the caller still shells out rather than silently skipping.
+ """Test fails closed when ioctl refuses."""
+ target = tmp_path / "file.txt"
+ target.write_text("data", encoding="utf-8")
+ with patch("fcntl.ioctl", side_effect=OSError("unsupported")):
+ assert _immutable_flag_is(target, immutable=False) is False
+ assert _immutable_flag_is(target, immutable=True) is False
+
+ def test_fails_closed_on_value_error(self, tmp_path: Path) -> None:
+ """Test fails closed on value error."""
+ target = tmp_path / "file.txt"
+ target.write_text("data", encoding="utf-8")
+ with patch("fcntl.ioctl", side_effect=ValueError("bad buffer")):
+ assert _immutable_flag_is(target, immutable=True) is False
+
+ def test_fails_closed_when_file_cannot_be_opened(self, tmp_path: Path) -> None:
+ """Test fails closed when file cannot be opened."""
+ assert _immutable_flag_is(tmp_path / "missing.txt", immutable=True) is False
+
+ def test_reports_set_flag(self, tmp_path: Path) -> None:
+ """Test reports set flag."""
+ target = tmp_path / "file.txt"
+ target.write_text("data", encoding="utf-8")
+
+ def _fake_ioctl(_fd: int, _req: int, buf: array.array[int]) -> int:
+ """Test fake ioctl."""
+ buf[0] = 0x00000010 # FS_IMMUTABLE_FL
+ return 0
+
+ with patch("fcntl.ioctl", side_effect=_fake_ioctl):
+ assert _immutable_flag_is(target, immutable=True) is True
+ assert _immutable_flag_is(target, immutable=False) is False
+
+ def test_oserror_swallowed(self, tmp_path: Path) -> None:
+ """Test oserror swallowed."""
+ 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:
+ """Test timeout swallowed."""
+ 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:
+ """Tests for Lock And Unlock."""
+
+ def test_lock_enforcement_files(self, tmp_path: Path) -> None:
+ """Test lock enforcement files."""
+ cfg = tmp_path / "config.json"
+ cfg.write_text("{}", encoding="utf-8")
+ approved = tmp_path / "approved.json"
+ approved.write_text("[]", encoding="utf-8")
+
+ with (
+ patch(
+ "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:
+ """Test unlock for write."""
+ target = tmp_path / "file.txt"
+ target.write_text("data", encoding="utf-8")
+ with (
+ patch("shutil.which", return_value="/usr/bin/chattr"),
+ # Pretend the file really is immutable; a fresh tmp file is not,
+ # and unlocking an already-mutable file is now a no-op.
+ patch(
+ "steam_backlog_enforcer._whitelist._immutable_flag_is",
+ return_value=False,
+ ),
+ patch("subprocess.run") as mock_run,
+ ):
+ unlock_for_write(target)
+ args = mock_run.call_args[0][0]
+ assert "-i" in args
+
+ def test_unlock_for_write_skips_already_mutable_file(self, tmp_path: Path) -> None:
+ """Test unlock for write skips already mutable file."""
+ target = tmp_path / "file.txt"
+ target.write_text("data", encoding="utf-8")
+ with patch("subprocess.run") as mock_run:
+ unlock_for_write(target)
+ mock_run.assert_not_called()
+
+
+class TestPersistence:
+ """Tests for Persistence."""
+
+ def test_load_approved_missing_file(self) -> None:
+ """Test load approved missing file."""
+ assert _load_approved() == []
+
+ def test_load_approved_corrupt_file(self, tmp_path: Path) -> None:
+ """Test load approved corrupt file."""
+ bad = tmp_path / "approved.json"
+ bad.write_text("{{broken", encoding="utf-8")
+ with patch(
+ "steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE",
+ bad,
+ ):
+ assert _load_approved() == []
+
+ def test_load_approved_non_list(self, tmp_path: Path) -> None:
+ """Test load approved non list."""
+ bad = tmp_path / "approved.json"
+ bad.write_text('"just a string"', encoding="utf-8")
+ with patch(
+ "steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE",
+ bad,
+ ):
+ assert _load_approved() == []
+
+ def test_save_approved_roundtrip(self) -> None:
+ """Test save approved roundtrip."""
+ 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
diff --git a/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is_append_audit_log.py b/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is_append_audit_log.py
new file mode 100644
index 0000000..8c10f7e
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is_append_audit_log.py
@@ -0,0 +1,89 @@
+"""Tests for _whitelist.py: time-locked exceptions, reason validation, chattr."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from unittest.mock import patch
+
+from steam_backlog_enforcer._whitelist import (
+ _append_audit_log,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+# ──────────────────────────────────────────────────────────────
+# Helpers
+# ──────────────────────────────────────────────────────────────
+
+_VALID_REASON = "I need this game installed for a work presentation this week."
+
+
+# ──────────────────────────────────────────────────────────────
+# Shannon entropy
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# validate_reason
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# chattr helpers
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# Persistence helpers (_load_approved, _save_approved)
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# Audit log
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# add_pending_exception
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# get_approved_exception_ids
+# ──────────────────────────────────────────────────────────────
+
+
+# ──────────────────────────────────────────────────────────────
+# Extra coverage for validate_reason branches 94 & 106
+# ──────────────────────────────────────────────────────────────
+
+
+class TestAppendAuditLog:
+ """Tests for Append Audit Log."""
+
+ def test_audit_log_written(self, tmp_path: Path) -> None:
+ """Test audit log written."""
+ log_file = tmp_path / "audit.log"
+ with patch(
+ "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:
+ """Test audit log appends."""
+ log_file = tmp_path / "audit.log"
+ with patch(
+ "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