Bring every test file under the 250-line cap

The pre-push ci-mirror runs the file-length gate over the whole tree, not
the diff, so ~30 files left it red and no push could land. This clears the
test half: 18 modules split into 60, plus the docstrings pylint wants on
every class and test method.

Splits are by responsibility, never mechanical partN names -- files are
named for the first class or test they contain (test_stats_sum_hours,
test_store_blocker_unblock_store, ...). Where a file was a single oversized
class, its test methods are partitioned into sibling classes instead, with
fixtures and helper classes copied into every part: a method-level split
that drops an autouse fixture fails at runtime, not at import, so it is easy
to miss.

test_polls_tracking_part2 keeps its name only because it still holds one
indivisible class; the rest of the partN files are gone.

1347 tests pass, unchanged in count and name from before the split -- the
splitter was checked by diffing test-function names, since a test silently
lost in a partition looks identical to one that never existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
This commit is contained in:
Krzysztof Rudnicki 2026-08-24 14:16:08 +02:00
parent 919ecdfd62
commit 9a2dd4fb73
64 changed files with 7242 additions and 5041 deletions

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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()

View File

@ -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()

View File

@ -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"

View File

@ -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

View File

@ -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()

View File

@ -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 = (
'<script id="__NEXT_DATA__" type="application/json">'
+ json.dumps(next_data)
+ "</script>"
)
resp = _FakeTextResponse(200, html)
session = MagicMock()
session.get = MagicMock(return_value=resp)
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
assert result == game_data
def test_non_200(self) -> None:
resp = _FakeTextResponse(404)
session = MagicMock()
session.get = MagicMock(return_value=resp)
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
assert result is None
def test_client_error(self) -> None:
ctx = AsyncMock()
ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError)
ctx.__aexit__ = AsyncMock(return_value=False)
session = MagicMock()
session.get = MagicMock(return_value=ctx)
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
assert result is None
def test_parse_failure(self) -> None:
resp = _FakeTextResponse(200, "<html>no script</html>")
session = MagicMock()
session.get = MagicMock(return_value=resp)
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
assert result is None
class TestFetchLeisureTimes:
"""Tests for _fetch_leisure_times."""
def test_updates_cache(self) -> None:
results = [
HLTBResult(
app_id=440,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=12345,
),
]
game_data: dict[str, Any] = {
"game": [{"comp_100_h": 21243}],
"relationships": [],
}
cache: dict[int, float] = {}
with patch(
"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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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 = (
'<script id="__NEXT_DATA__" type="application/json">'
+ json.dumps(next_data)
+ "</script>"
)
resp = _FakeTextResponse(200, html)
session = MagicMock()
session.get = MagicMock(return_value=resp)
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
assert result == game_data
def test_non_200(self) -> None:
"""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, "<html>no script</html>")
session = MagicMock()
session.get = MagicMock(return_value=resp)
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
assert result is None

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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."""

View File

@ -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

View File

@ -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."""

View File

@ -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("<html></html>") is None
def test_bad_json(self) -> None:
"""Test bad json."""
html = '<script id="__NEXT_DATA__" type="application/json">{not json}</script>'
assert _parse_game_page(html) is None
def test_missing_keys(self) -> None:
"""Test missing keys."""
html = (
'<script id="__NEXT_DATA__" type="application/json">{"props": {}}</script>'
)
assert _parse_game_page(html) is None
class TestExtractLeisureHours:
"""Tests for _extract_leisure_hours."""
def test_leisure_time_only(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
"relationships": [],
}
assert _extract_leisure_hours(data) == round(21243 / 3600, 2)
def test_leisure_with_dlc(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
"relationships": [
{"game_type": "dlc", "comp_100": 12298},
{"game_type": "dlc", "comp_100": 3600},
],
}
assert _extract_leisure_hours(data) == round((21243 + 12298 + 3600) / 3600, 2)
def test_fallback_to_comp_100(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100": 7200}],
"relationships": [],
}
assert _extract_leisure_hours(data) == round(7200 / 3600, 2)
def test_no_game_data(self) -> None:
assert _extract_leisure_hours({"game": [], "relationships": []}) == -1
def test_zero_leisure(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 0, "comp_100": 0}],
"relationships": [],
}
assert _extract_leisure_hours(data) == -1
def test_no_game_key(self) -> None:
assert _extract_leisure_hours({"relationships": []}) == -1
def test_non_dlc_relationship_ignored(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 3600}],
"relationships": [
{"game_type": "game", "comp_100": 9999},
{"game_type": "dlc", "comp_100": 1800},
],
}
assert _extract_leisure_hours(data) == round((3600 + 1800) / 3600, 2)
def test_dlc_zero_comp_100_skipped(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 3600}],
"relationships": [
{"game_type": "dlc", "comp_100": 0},
],
}
assert _extract_leisure_hours(data) == round(3600 / 3600, 2)
def test_negative_leisure(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": -1, "comp_100": -1}],
"relationships": [],
}
assert _extract_leisure_hours(data) == -1
def test_string_numeric_fields(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": "7200", "comp_100": "3600"}],
"relationships": [{"game_type": "dlc", "game_id": "1", "comp_100": "1800"}],
}
assert _extract_leisure_hours(data) == round((7200 + 1800) / 3600, 2)
def test_bad_string_falls_back_to_comp_100(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": "bad", "comp_100": "3600"}],
"relationships": [],
}
assert _extract_leisure_hours(data) == 1.0
def test_relationships_not_list(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 3600}],
"relationships": "not-a-list",
}
assert _extract_leisure_hours(data) == 1.0
class TestBuildSearchVariants:
"""Tests for _build_search_variants."""
def test_subtitle_with_edition_strips_edition_from_subtitle_part(self) -> None:
# "Rocksmith 2014 Edition - Remastered" → no_subtitle = "Rocksmith 2014 Edition"
# (which != base), so lines 201-202 also add "Rocksmith" and "Rocksmith 2014"
variants = _build_search_variants("Rocksmith 2014 Edition - Remastered")
assert "Rocksmith 2014 Edition" in variants
assert "Rocksmith 2014" in variants
assert "Rocksmith" in variants
def test_no_subtitle_skips_edition_strip(self) -> None:
# No " - " → no_subtitle == base → lines 201-202 are not executed
variants = _build_search_variants("Portal 2")
assert "Portal 2" in variants

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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
)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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