mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 18:13:49 +01:00
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:
parent
919ecdfd62
commit
9a2dd4fb73
@ -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"
|
||||
|
||||
206
steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py
Normal file
206
steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py
Normal 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
|
||||
88
steam_backlog_enforcer/tests/test_actions_status_payload.py
Normal file
88
steam_backlog_enforcer/tests/test_actions_status_payload.py
Normal 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"
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
128
steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py
Normal file
128
steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
121
steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry.py
Normal file
121
steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry.py
Normal 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"
|
||||
@ -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"
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
@ -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."""
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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()
|
||||
@ -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"
|
||||
|
||||
89
steam_backlog_enforcer/tests/test_protondb_fetch_batch.py
Normal file
89
steam_backlog_enforcer/tests/test_protondb_fetch_batch.py
Normal 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"
|
||||
64
steam_backlog_enforcer/tests/test_protondb_fetch_one.py
Normal file
64
steam_backlog_enforcer/tests/test_protondb_fetch_one.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
133
steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py
Normal file
133
steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py
Normal 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
|
||||
)
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
156
steam_backlog_enforcer/tests/test_scanning_pick_next_game.py
Normal file
156
steam_backlog_enforcer/tests/test_scanning_pick_next_game.py
Normal 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()
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
224
steam_backlog_enforcer/tests/test_stats_cmd_stats.py
Normal file
224
steam_backlog_enforcer/tests/test_stats_cmd_stats.py
Normal 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)
|
||||
@ -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
|
||||
128
steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py
Normal file
128
steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py
Normal 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
|
||||
@ -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)
|
||||
162
steam_backlog_enforcer/tests/test_stats_print_worst_example.py
Normal file
162
steam_backlog_enforcer/tests/test_stats_print_worst_example.py
Normal 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)
|
||||
@ -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
|
||||
177
steam_backlog_enforcer/tests/test_stats_sum_hours.py
Normal file
177
steam_backlog_enforcer/tests/test_stats_sum_hours.py
Normal 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)
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
213
steam_backlog_enforcer/tests/test_web_dataset_build_games.py
Normal file
213
steam_backlog_enforcer/tests/test_web_dataset_build_games.py
Normal 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
|
||||
@ -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"
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
225
steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py
Normal file
225
steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py
Normal 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
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user