diff --git a/steam_backlog_enforcer/tests/test_actions.py b/steam_backlog_enforcer/tests/test_actions.py index 641fcc1..96bd197 100644 --- a/steam_backlog_enforcer/tests/test_actions.py +++ b/steam_backlog_enforcer/tests/test_actions.py @@ -3,25 +3,16 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone -from unittest.mock import patch import pytest from steam_backlog_enforcer import _actions, _allowed_games from steam_backlog_enforcer._actions import ( - abandon_manual_pick, - active_manual_picks, - allowed_app_ids, - allowed_games, apply_manual_pick, can_abandon_manual_pick, - find_manual_pick, is_manual_pick_locked, manual_pick_grace_remaining, - manual_pick_slots_left, - status_payload, ) -from steam_backlog_enforcer._total_block import TotalBlockStatus from steam_backlog_enforcer.config import State @@ -29,11 +20,23 @@ def _iso_days_ago(days: float) -> str: return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() +def _pick(app_id: int = 440, name: str = "TF2", days_ago: float = 1.0) -> dict: + return { + "app_id": app_id, + "game_name": name, + "started_at": _iso_days_ago(days_ago), + } + + class TestIsManualPickLocked: + """Tests for Is Manual Pick Locked.""" + def test_no_pick_is_unlocked(self) -> None: + """Test no pick is unlocked.""" assert is_manual_pick_locked(State()) is False def test_finished_game_releases_lock(self) -> None: + """Test finished game releases lock.""" state = State( manual_picks=[{"app_id": 5, "game_name": "G", "started_at": ""}], finished_app_ids=[5], @@ -42,10 +45,12 @@ class TestIsManualPickLocked: def test_empty_timestamp_stays_locked(self) -> None: # Pick set but no started_at → locked (no expiry to evaluate). + """Test empty timestamp stays locked.""" state = State(manual_picks=[{"app_id": 5, "game_name": "G", "started_at": ""}]) assert is_manual_pick_locked(state) is True def test_recent_pick_is_locked(self) -> None: + """Test recent pick is locked.""" state = State( manual_picks=[ {"app_id": 5, "game_name": "G", "started_at": _iso_days_ago(1)} @@ -54,6 +59,7 @@ class TestIsManualPickLocked: assert is_manual_pick_locked(state) is True def test_expired_pick_releases_lock(self) -> None: + """Test expired pick releases lock.""" state = State( manual_picks=[ { @@ -67,6 +73,7 @@ class TestIsManualPickLocked: def test_one_active_pick_holds_the_lock_for_all(self) -> None: # Multi-pick: the lock only lifts when every pick is done/expired. + """Test one active pick holds the lock for all.""" state = State( manual_picks=[ {"app_id": 5, "game_name": "Done", "started_at": _iso_days_ago(1)}, @@ -81,6 +88,7 @@ class TestLegacyManualPickMigration: """A lock written by the old single-slot code must survive the upgrade.""" def test_legacy_pick_is_migrated_on_load(self) -> None: + """Test legacy pick is migrated on load.""" started = _iso_days_ago(1) State( manual_pick_app_id=5, @@ -98,16 +106,21 @@ class TestLegacyManualPickMigration: assert is_manual_pick_locked(loaded) is True def test_new_format_is_left_alone(self) -> None: + """Test new format is left alone.""" State(manual_picks=[{"app_id": 9, "game_name": "New", "started_at": ""}]).save() assert [p["app_id"] for p in State.load().manual_picks] == [9] def test_no_pick_needs_no_migration(self) -> None: + """Test no pick needs no migration.""" State().save() assert State.load().manual_picks == [] class TestApplyManualPick: + """Tests for Apply Manual Pick.""" + def test_sets_all_fields_and_enforcement_start(self) -> None: + """Test sets all fields and enforcement start.""" state = State() assert apply_manual_pick(state, 440, "Team Fortress 2") is None assert [p["app_id"] for p in state.manual_picks] == [440] @@ -119,43 +132,44 @@ class TestApplyManualPick: assert state.enforcement_started_at != "" def test_preserves_existing_enforcement_start(self) -> None: + """Test preserves existing enforcement start.""" state = State(enforcement_started_at="2020-01-01T00:00:00+00:00") apply_manual_pick(state, 620, "Portal 2") assert state.enforcement_started_at == "2020-01-01T00:00:00+00:00" def test_persists_to_disk(self) -> None: + """Test persists to disk.""" state = State() apply_manual_pick(state, 70, "Half-Life") assert State.load().current_app_id == 70 def test_default_cap_is_one(self) -> None: # Callers that do not opt in keep the historical single-slot behaviour. + """Test default cap is one.""" state = State() apply_manual_pick(state, 440, "TF2") assert apply_manual_pick(state, 620, "Portal 2") is not None -def _pick(app_id: int = 440, name: str = "TF2", days_ago: float = 1.0) -> dict: - return { - "app_id": app_id, - "game_name": name, - "started_at": _iso_days_ago(days_ago), - } - - class TestManualPickGraceRemaining: + """Tests for Manual Pick Grace Remaining.""" + def test_no_pick_returns_none(self) -> None: + """Test no pick returns none.""" assert manual_pick_grace_remaining(State(), 440) is None def test_unknown_app_id_returns_none(self) -> None: + """Test unknown app id returns none.""" state = State(manual_picks=[_pick(440)]) assert manual_pick_grace_remaining(state, 999) is None def test_missing_timestamp_returns_none(self) -> None: + """Test missing timestamp returns none.""" state = State(manual_picks=[{"app_id": 440, "game_name": "TF2"}]) assert manual_pick_grace_remaining(state, 440) is None def test_malformed_timestamp_returns_none(self) -> None: + """Test malformed timestamp returns none.""" state = State( manual_picks=[ {"app_id": 440, "game_name": "TF2", "started_at": "not-a-date"} @@ -164,12 +178,14 @@ class TestManualPickGraceRemaining: assert manual_pick_grace_remaining(state, 440) is None def test_fresh_pick_has_almost_the_full_window(self) -> None: + """Test fresh pick has almost the full window.""" state = State(manual_picks=[_pick(days_ago=0)]) remaining = manual_pick_grace_remaining(state, 440) assert remaining is not None assert remaining == pytest.approx(_actions.MANUAL_GRACE_DAYS, abs=0.01) def test_expired_window_is_negative(self) -> None: + """Test expired window is negative.""" state = State(manual_picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS + 1)]) remaining = manual_pick_grace_remaining(state, 440) assert remaining is not None @@ -177,221 +193,18 @@ class TestManualPickGraceRemaining: class TestCanAbandonManualPick: + """Tests for Can Abandon Manual Pick.""" + def test_inside_window(self) -> None: + """Test inside window.""" state = State(manual_picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS - 1)]) assert can_abandon_manual_pick(state, 440) is True def test_outside_window(self) -> None: + """Test outside window.""" state = State(manual_picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS + 1)]) assert can_abandon_manual_pick(state, 440) is False def test_no_pick(self) -> None: + """Test no pick.""" assert can_abandon_manual_pick(State(), 440) is False - - -class TestAbandonManualPick: - def _state(self, *, picks: list[dict] | None = None) -> State: - picks = picks if picks is not None else [_pick()] - return State( - manual_picks=picks, - current_app_id=picks[-1]["app_id"], - current_game_name=picks[-1]["game_name"], - ) - - def test_clears_lock_and_assignment(self) -> None: - state = self._state() - assert abandon_manual_pick(state, 440) is True - assert state.manual_picks == [] - assert state.current_app_id is None - assert state.current_game_name == "" - assert is_manual_pick_locked(state) is False - - def test_records_cooldown(self) -> None: - state = self._state() - abandon_manual_pick(state, 440) - expiry = datetime.fromisoformat(state.skipped_until["440"]) - expected = datetime.now(timezone.utc) + timedelta( - days=_actions.ABANDON_COOLDOWN_DAYS - ) - assert abs((expiry - expected).total_seconds()) < 60 - - def test_persists_to_disk(self) -> None: - abandon_manual_pick(self._state(), 440) - assert State.load().manual_picks == [] - - def test_refuses_after_grace_and_leaves_state_untouched(self) -> None: - state = self._state( - picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS + 1)], - ) - assert abandon_manual_pick(state, 440) is False - assert [p["app_id"] for p in state.manual_picks] == [440] - assert state.current_app_id == 440 - assert state.skipped_until == {} - - def test_other_pick_survives_and_takes_the_assignment(self) -> None: - # The whole point of multi-pick: abandoning one keeps the other locked. - state = self._state(picks=[_pick(440, "TF2"), _pick(620, "Portal 2")]) - assert abandon_manual_pick(state, 620) is True - assert [p["app_id"] for p in state.manual_picks] == [440] - assert state.current_app_id == 440 - assert state.current_game_name == "TF2" - assert is_manual_pick_locked(state) is True - - def test_keeps_unrelated_assignment(self) -> None: - # A pick that is not the current assignment must not clear it. - state = self._state(picks=[_pick(440, "TF2")]) - state.current_app_id = 70 - state.current_game_name = "Half-Life" - abandon_manual_pick(state, 440) - assert state.current_app_id == 70 - assert state.current_game_name == "Half-Life" - - -class TestActiveManualPicksAndAllowedSet: - def test_finished_pick_drops_out(self) -> None: - state = State(manual_picks=[_pick(440)], finished_app_ids=[440]) - assert active_manual_picks(state) == [] - assert is_manual_pick_locked(state) is False - - def test_expired_pick_drops_out(self) -> None: - state = State( - manual_picks=[_pick(days_ago=_allowed_games.MANUAL_LOCK_DAYS + 1)], - ) - assert active_manual_picks(state) == [] - - def test_missing_timestamp_stays_active(self) -> None: - # No deadline to evaluate → stay locked (safe answer for an enforcer). - state = State(manual_picks=[{"app_id": 440, "game_name": "TF2"}]) - assert len(active_manual_picks(state)) == 1 - - def test_malformed_timestamp_stays_active(self) -> None: - state = State( - manual_picks=[ - {"app_id": 440, "game_name": "TF2", "started_at": "not-a-date"} - ] - ) - assert len(active_manual_picks(state)) == 1 - - def test_entry_without_app_id_is_ignored(self) -> None: - state = State(manual_picks=[{"game_name": "Corrupt"}]) - assert active_manual_picks(state) == [] - - def test_allowed_set_unions_picks_and_assignment(self) -> None: - state = State( - manual_picks=[_pick(440, "TF2"), _pick(620, "Portal 2")], - current_app_id=70, - current_game_name="Half-Life", - ) - assert allowed_app_ids(state) == {70, 440, 620} - assert allowed_games(state)[0] == (70, "Half-Life") - - def test_allowed_set_deduplicates_assignment(self) -> None: - state = State( - manual_picks=[_pick(440, "TF2")], - current_app_id=440, - current_game_name="TF2", - ) - assert allowed_app_ids(state) == {440} - assert allowed_games(state) == [(440, "TF2")] - - def test_empty_state_allows_nothing(self) -> None: - assert allowed_app_ids(State()) == set() - assert allowed_games(State()) == [] - - def test_find_manual_pick(self) -> None: - state = State(manual_picks=[_pick(440, "TF2")]) - found = find_manual_pick(state, 440) - assert found is not None - assert found["game_name"] == "TF2" - assert find_manual_pick(state, 999) is None - - -class TestApplyManualPickCap: - def test_appends_second_pick(self) -> None: - state = State(manual_picks=[_pick(440, "TF2")]) - assert apply_manual_pick(state, 620, "Portal 2", max_picks=2) is None - assert [p["app_id"] for p in state.manual_picks] == [440, 620] - # Newest pick becomes the assignment. - assert state.current_app_id == 620 - - def test_refuses_beyond_cap(self) -> None: - state = State(manual_picks=[_pick(440, "TF2"), _pick(620, "Portal 2")]) - refused = apply_manual_pick(state, 70, "Half-Life", max_picks=2) - assert refused is not None - assert "cap is 2" in refused - assert len(state.manual_picks) == 2 - - def test_refuses_duplicate_pick(self) -> None: - state = State(manual_picks=[_pick(440, "TF2")]) - refused = apply_manual_pick(state, 440, "TF2", max_picks=2) - assert refused is not None - assert "already one of your manual picks" in refused - - def test_prunes_finished_entries(self) -> None: - state = State(manual_picks=[_pick(440, "TF2")], finished_app_ids=[440]) - assert apply_manual_pick(state, 620, "Portal 2", max_picks=2) is None - assert [p["app_id"] for p in state.manual_picks] == [620] - - def test_slots_left(self) -> None: - state = State(manual_picks=[_pick(440, "TF2")]) - assert manual_pick_slots_left(state, 2) == 1 - assert manual_pick_slots_left(state, 1) == 0 - - -class TestStatusPayload: - def _patch_leaves( - self, - *, - total_block: TotalBlockStatus, - installed: list[tuple[int, str]], - store_blocked: bool, - protected_ids: set[int], - ) -> object: - return patch.multiple( - "steam_backlog_enforcer._actions", - get_total_block_status=lambda: total_block, - get_installed_games=lambda: installed, - is_store_blocked=lambda: store_blocked, - is_protected_app=lambda aid: aid in protected_ids, - ) - - def test_assigned_and_installed(self) -> None: - block = TotalBlockStatus( - active=False, started_at=None, until=None, days=0, days_remaining=0.0 - ) - with self._patch_leaves( - total_block=block, - installed=[(440, "TF2"), (70, "HL")], - store_blocked=True, - protected_ids=set(), - ): - payload = status_payload(State(current_app_id=440, current_game_name="TF2")) - assert payload["current_app_id"] == 440 - assert payload["installed_count"] == 2 - assert payload["assigned_game_installed"] is True - assert payload["store_blocked"] is True - assert payload["manual_pick_locked"] is False - - def test_no_assignment_and_protected_filtering(self) -> None: - block = TotalBlockStatus( - active=True, - started_at=None, - until=datetime(2030, 1, 1, tzinfo=timezone.utc), - days=3, - days_remaining=2.55, - ) - with self._patch_leaves( - total_block=block, - installed=[(440, "TF2"), (228980, "Steamworks")], - store_blocked=False, - protected_ids={228980}, - ): - payload = status_payload(State()) - assert payload["current_app_id"] is None - assert payload["current_game_name"] is None - assert payload["assigned_game_installed"] is None - # protected app filtered out of the count. - assert payload["installed_count"] == 1 - assert payload["total_block"]["active"] is True - assert payload["total_block"]["days_remaining"] == 2.5 - assert payload["total_block"]["until"] == "2030-01-01T00:00:00+00:00" diff --git a/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py b/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py new file mode 100644 index 0000000..e9c04c4 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py @@ -0,0 +1,206 @@ +"""Tests for the stdout-free, state-only cores in ``_actions``.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from steam_backlog_enforcer import _actions, _allowed_games +from steam_backlog_enforcer._actions import ( + abandon_manual_pick, + active_manual_picks, + allowed_app_ids, + allowed_games, + apply_manual_pick, + find_manual_pick, + is_manual_pick_locked, + manual_pick_slots_left, +) +from steam_backlog_enforcer.config import State + + +def _iso_days_ago(days: float) -> str: + return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + + +def _pick(app_id: int = 440, name: str = "TF2", days_ago: float = 1.0) -> dict: + return { + "app_id": app_id, + "game_name": name, + "started_at": _iso_days_ago(days_ago), + } + + +class TestAbandonManualPick: + """Tests for Abandon Manual Pick.""" + + def _state(self, *, picks: list[dict] | None = None) -> State: + """Test state.""" + picks = picks if picks is not None else [_pick()] + return State( + manual_picks=picks, + current_app_id=picks[-1]["app_id"], + current_game_name=picks[-1]["game_name"], + ) + + def test_clears_lock_and_assignment(self) -> None: + """Test clears lock and assignment.""" + state = self._state() + assert abandon_manual_pick(state, 440) is True + assert state.manual_picks == [] + assert state.current_app_id is None + assert state.current_game_name == "" + assert is_manual_pick_locked(state) is False + + def test_records_cooldown(self) -> None: + """Test records cooldown.""" + state = self._state() + abandon_manual_pick(state, 440) + expiry = datetime.fromisoformat(state.skipped_until["440"]) + expected = datetime.now(timezone.utc) + timedelta( + days=_actions.ABANDON_COOLDOWN_DAYS + ) + assert abs((expiry - expected).total_seconds()) < 60 + + def test_persists_to_disk(self) -> None: + """Test persists to disk.""" + abandon_manual_pick(self._state(), 440) + assert State.load().manual_picks == [] + + def test_refuses_after_grace_and_leaves_state_untouched(self) -> None: + """Test refuses after grace and leaves state untouched.""" + state = self._state( + picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS + 1)], + ) + assert abandon_manual_pick(state, 440) is False + assert [p["app_id"] for p in state.manual_picks] == [440] + assert state.current_app_id == 440 + assert state.skipped_until == {} + + def test_other_pick_survives_and_takes_the_assignment(self) -> None: + # The whole point of multi-pick: abandoning one keeps the other locked. + """Test other pick survives and takes the assignment.""" + state = self._state(picks=[_pick(440, "TF2"), _pick(620, "Portal 2")]) + assert abandon_manual_pick(state, 620) is True + assert [p["app_id"] for p in state.manual_picks] == [440] + assert state.current_app_id == 440 + assert state.current_game_name == "TF2" + assert is_manual_pick_locked(state) is True + + def test_keeps_unrelated_assignment(self) -> None: + # A pick that is not the current assignment must not clear it. + """Test keeps unrelated assignment.""" + state = self._state(picks=[_pick(440, "TF2")]) + state.current_app_id = 70 + state.current_game_name = "Half-Life" + abandon_manual_pick(state, 440) + assert state.current_app_id == 70 + assert state.current_game_name == "Half-Life" + + +class TestActiveManualPicksAndAllowedSet: + """Tests for Active Manual Picks And Allowed Set.""" + + def test_finished_pick_drops_out(self) -> None: + """Test finished pick drops out.""" + state = State(manual_picks=[_pick(440)], finished_app_ids=[440]) + assert active_manual_picks(state) == [] + assert is_manual_pick_locked(state) is False + + def test_expired_pick_drops_out(self) -> None: + """Test expired pick drops out.""" + state = State( + manual_picks=[_pick(days_ago=_allowed_games.MANUAL_LOCK_DAYS + 1)], + ) + assert active_manual_picks(state) == [] + + def test_missing_timestamp_stays_active(self) -> None: + # No deadline to evaluate → stay locked (safe answer for an enforcer). + """Test missing timestamp stays active.""" + state = State(manual_picks=[{"app_id": 440, "game_name": "TF2"}]) + assert len(active_manual_picks(state)) == 1 + + def test_malformed_timestamp_stays_active(self) -> None: + """Test malformed timestamp stays active.""" + state = State( + manual_picks=[ + {"app_id": 440, "game_name": "TF2", "started_at": "not-a-date"} + ] + ) + assert len(active_manual_picks(state)) == 1 + + def test_entry_without_app_id_is_ignored(self) -> None: + """Test entry without app id is ignored.""" + state = State(manual_picks=[{"game_name": "Corrupt"}]) + assert active_manual_picks(state) == [] + + def test_allowed_set_unions_picks_and_assignment(self) -> None: + """Test allowed set unions picks and assignment.""" + state = State( + manual_picks=[_pick(440, "TF2"), _pick(620, "Portal 2")], + current_app_id=70, + current_game_name="Half-Life", + ) + assert allowed_app_ids(state) == {70, 440, 620} + assert allowed_games(state)[0] == (70, "Half-Life") + + def test_allowed_set_deduplicates_assignment(self) -> None: + """Test allowed set deduplicates assignment.""" + state = State( + manual_picks=[_pick(440, "TF2")], + current_app_id=440, + current_game_name="TF2", + ) + assert allowed_app_ids(state) == {440} + assert allowed_games(state) == [(440, "TF2")] + + def test_empty_state_allows_nothing(self) -> None: + """Test empty state allows nothing.""" + assert allowed_app_ids(State()) == set() + assert allowed_games(State()) == [] + + def test_find_manual_pick(self) -> None: + """Test find manual pick.""" + state = State(manual_picks=[_pick(440, "TF2")]) + found = find_manual_pick(state, 440) + assert found is not None + assert found["game_name"] == "TF2" + assert find_manual_pick(state, 999) is None + + +class TestApplyManualPickCap: + """Tests for Apply Manual Pick Cap.""" + + def test_appends_second_pick(self) -> None: + """Test appends second pick.""" + state = State(manual_picks=[_pick(440, "TF2")]) + assert apply_manual_pick(state, 620, "Portal 2", max_picks=2) is None + assert [p["app_id"] for p in state.manual_picks] == [440, 620] + # Newest pick becomes the assignment. + assert state.current_app_id == 620 + + def test_refuses_beyond_cap(self) -> None: + """Test refuses beyond cap.""" + state = State(manual_picks=[_pick(440, "TF2"), _pick(620, "Portal 2")]) + refused = apply_manual_pick(state, 70, "Half-Life", max_picks=2) + assert refused is not None + assert "cap is 2" in refused + assert len(state.manual_picks) == 2 + + def test_refuses_duplicate_pick(self) -> None: + """Test refuses duplicate pick.""" + state = State(manual_picks=[_pick(440, "TF2")]) + refused = apply_manual_pick(state, 440, "TF2", max_picks=2) + assert refused is not None + assert "already one of your manual picks" in refused + + def test_prunes_finished_entries(self) -> None: + """Test prunes finished entries.""" + state = State(manual_picks=[_pick(440, "TF2")], finished_app_ids=[440]) + assert apply_manual_pick(state, 620, "Portal 2", max_picks=2) is None + assert [p["app_id"] for p in state.manual_picks] == [620] + + def test_slots_left(self) -> None: + """Test slots left.""" + state = State(manual_picks=[_pick(440, "TF2")]) + assert manual_pick_slots_left(state, 2) == 1 + assert manual_pick_slots_left(state, 1) == 0 diff --git a/steam_backlog_enforcer/tests/test_actions_status_payload.py b/steam_backlog_enforcer/tests/test_actions_status_payload.py new file mode 100644 index 0000000..a0db66f --- /dev/null +++ b/steam_backlog_enforcer/tests/test_actions_status_payload.py @@ -0,0 +1,88 @@ +"""Tests for the stdout-free, state-only cores in ``_actions``.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +from steam_backlog_enforcer._actions import ( + status_payload, +) +from steam_backlog_enforcer._total_block import TotalBlockStatus +from steam_backlog_enforcer.config import State + + +def _iso_days_ago(days: float) -> str: + return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + + +def _pick(app_id: int = 440, name: str = "TF2", days_ago: float = 1.0) -> dict: + return { + "app_id": app_id, + "game_name": name, + "started_at": _iso_days_ago(days_ago), + } + + +class TestStatusPayload: + """Tests for Status Payload.""" + + def _patch_leaves( + self, + *, + total_block: TotalBlockStatus, + installed: list[tuple[int, str]], + store_blocked: bool, + protected_ids: set[int], + ) -> object: + """Test patch leaves.""" + return patch.multiple( + "steam_backlog_enforcer._actions", + get_total_block_status=lambda: total_block, + get_installed_games=lambda: installed, + is_store_blocked=lambda: store_blocked, + is_protected_app=lambda aid: aid in protected_ids, + ) + + def test_assigned_and_installed(self) -> None: + """Test assigned and installed.""" + block = TotalBlockStatus( + active=False, started_at=None, until=None, days=0, days_remaining=0.0 + ) + with self._patch_leaves( + total_block=block, + installed=[(440, "TF2"), (70, "HL")], + store_blocked=True, + protected_ids=set(), + ): + payload = status_payload(State(current_app_id=440, current_game_name="TF2")) + assert payload["current_app_id"] == 440 + assert payload["installed_count"] == 2 + assert payload["assigned_game_installed"] is True + assert payload["store_blocked"] is True + assert payload["manual_pick_locked"] is False + + def test_no_assignment_and_protected_filtering(self) -> None: + """Test no assignment and protected filtering.""" + block = TotalBlockStatus( + active=True, + started_at=None, + until=datetime(2030, 1, 1, tzinfo=timezone.utc), + days=3, + days_remaining=2.55, + ) + with self._patch_leaves( + total_block=block, + installed=[(440, "TF2"), (228980, "Steamworks")], + store_blocked=False, + protected_ids={228980}, + ): + payload = status_payload(State()) + assert payload["current_app_id"] is None + assert payload["current_game_name"] is None + assert payload["assigned_game_installed"] is None + # protected app filtered out of the count. + assert payload["installed_count"] == 1 + assert payload["total_block"]["active"] is True + assert payload["total_block"]["days_remaining"] == 2.5 + assert payload["total_block"]["until"] == "2030-01-01T00:00:00+00:00" diff --git a/steam_backlog_enforcer/tests/test_config.py b/steam_backlog_enforcer/tests/test_config.py index f04f953..138fe3b 100644 --- a/steam_backlog_enforcer/tests/test_config.py +++ b/steam_backlog_enforcer/tests/test_config.py @@ -12,7 +12,6 @@ from steam_backlog_enforcer.config import ( Config, State, _atomic_write, - interactive_setup, load_snapshot, save_snapshot, ) @@ -25,16 +24,19 @@ class TestAtomicWrite: """Tests for _atomic_write.""" def test_writes_file(self, tmp_path: Path) -> None: + """Test writes file.""" target = tmp_path / "out.json" _atomic_write(target, '{"key": "value"}\n') assert target.read_text(encoding="utf-8") == '{"key": "value"}\n' def test_creates_parent_dirs(self, tmp_path: Path) -> None: + """Test creates parent dirs.""" target = tmp_path / "sub" / "deep" / "out.json" _atomic_write(target, "data") assert target.read_text(encoding="utf-8") == "data" def test_cleanup_on_write_error(self, tmp_path: Path) -> None: + """Test cleanup on write error.""" target = tmp_path / "out.json" with ( patch( @@ -49,6 +51,7 @@ class TestAtomicWrite: assert tmp_files == [] def test_cleanup_on_replace_error(self, tmp_path: Path) -> None: + """Test cleanup on replace error.""" target = tmp_path / "out.json" with ( patch.object( @@ -68,6 +71,7 @@ class TestConfig: """Tests for Config dataclass.""" def test_defaults(self) -> None: + """Test defaults.""" cfg = Config() assert cfg.steam_api_key == "" assert cfg.steam_id == "" @@ -77,6 +81,7 @@ class TestConfig: assert cfg.desktop_notifications is True def test_save(self, tmp_path: Path) -> None: + """Test save.""" cfg = Config(steam_api_key="abc", steam_id="123") config_dir = tmp_path / "cfg" config_file = config_dir / "config.json" @@ -90,6 +95,7 @@ class TestConfig: assert data["steam_id"] == "123" def test_load_existing(self, tmp_path: Path) -> None: + """Test load existing.""" config_file = tmp_path / "config.json" config_file.write_text( json.dumps({"steam_api_key": "key1", "steam_id": "id1"}) + "\n", @@ -101,12 +107,14 @@ class TestConfig: assert cfg.steam_id == "id1" def test_load_missing(self, tmp_path: Path) -> None: + """Test load missing.""" config_file = tmp_path / "nonexistent.json" with patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file): cfg = Config.load() assert cfg.steam_api_key == "" def test_load_extra_fields_ignored(self, tmp_path: Path) -> None: + """Test load extra fields ignored.""" config_file = tmp_path / "config.json" config_file.write_text( json.dumps({"steam_api_key": "k", "unknown_field": 42}) + "\n", @@ -121,12 +129,14 @@ class TestState: """Tests for State dataclass.""" def test_defaults(self) -> None: + """Test defaults.""" state = State() assert state.current_app_id is None assert state.current_game_name == "" assert state.finished_app_ids == [] def test_save(self, tmp_path: Path) -> None: + """Test save.""" state = State(current_app_id=100, current_game_name="TestGame") config_dir = tmp_path / "cfg" state_file = config_dir / "state.json" @@ -140,6 +150,7 @@ class TestState: assert data["current_game_name"] == "TestGame" def test_load_existing(self, tmp_path: Path) -> None: + """Test load existing.""" state_file = tmp_path / "state.json" state_file.write_text( json.dumps( @@ -158,12 +169,14 @@ class TestState: assert st.finished_app_ids == [1, 2] def test_load_missing(self, tmp_path: Path) -> None: + """Test load missing.""" state_file = tmp_path / "nonexistent.json" with patch("steam_backlog_enforcer.config.STATE_FILE", state_file): st = State.load() assert st.current_app_id is None def test_load_corrupt(self, tmp_path: Path) -> None: + """Test load corrupt.""" state_file = tmp_path / "state.json" state_file.write_text("not valid json{{", encoding="utf-8") with patch("steam_backlog_enforcer.config.STATE_FILE", state_file): @@ -172,6 +185,7 @@ class TestState: assert st.current_game_name == "" def test_skip_for_days_records_iso_timestamp(self) -> None: + """Test skip for days records iso timestamp.""" state = State() state.skip_for_days(42, 7) assert "42" in state.skipped_until @@ -183,6 +197,7 @@ class TestState: assert 6 * 86400 < delta <= 7 * 86400 + 1 def test_active_skipped_ids_returns_active(self) -> None: + """Test active skipped ids returns active.""" from datetime import datetime, timedelta, timezone state = State() @@ -193,6 +208,7 @@ class TestState: assert "100" in state.skipped_until def test_active_skipped_ids_prunes_expired(self) -> None: + """Test active skipped ids prunes expired.""" from datetime import datetime, timedelta, timezone state = State() @@ -202,6 +218,7 @@ class TestState: assert "50" not in state.skipped_until def test_active_skipped_ids_prunes_malformed(self) -> None: + """Test active skipped ids prunes malformed.""" state = State() state.skipped_until["77"] = "not-a-date" assert state.active_skipped_ids() == set() @@ -212,6 +229,7 @@ class TestSnapshot: """Tests for snapshot save/load.""" def test_save_and_load(self, tmp_path: Path) -> None: + """Test save and load.""" config_dir = tmp_path / "cfg" snap_file = config_dir / "snapshot.json" with ( @@ -224,41 +242,7 @@ class TestSnapshot: assert loaded == data def test_load_none(self, tmp_path: Path) -> None: + """Test load none.""" snap_file = tmp_path / "nonexistent.json" with patch("steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file): assert load_snapshot() is None - - -class TestInteractiveSetup: - """Tests for interactive_setup.""" - - def test_success(self, tmp_path: Path) -> None: - config_dir = tmp_path / "cfg" - config_file = config_dir / "config.json" - with ( - patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir), - patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file), - patch("builtins.input", side_effect=["mykey", "myid"]), - ): - cfg = interactive_setup() - assert cfg.steam_api_key == "mykey" - assert cfg.steam_id == "myid" - assert config_file.exists() - - def test_empty_api_key_exits(self) -> None: - with ( - patch("builtins.input", return_value=""), - pytest.raises(SystemExit), - ): - interactive_setup() - - def test_empty_steam_id_exits(self, tmp_path: Path) -> None: - config_dir = tmp_path / "cfg" - config_file = config_dir / "config.json" - with ( - patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir), - patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file), - patch("builtins.input", side_effect=["key", ""]), - pytest.raises(SystemExit), - ): - interactive_setup() diff --git a/steam_backlog_enforcer/tests/test_config_interactive_setup.py b/steam_backlog_enforcer/tests/test_config_interactive_setup.py new file mode 100644 index 0000000..e8b1afb --- /dev/null +++ b/steam_backlog_enforcer/tests/test_config_interactive_setup.py @@ -0,0 +1,53 @@ +"""Tests for config module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer.config import ( + interactive_setup, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestInteractiveSetup: + """Tests for interactive_setup.""" + + def test_success(self, tmp_path: Path) -> None: + """Test success.""" + config_dir = tmp_path / "cfg" + config_file = config_dir / "config.json" + with ( + patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir), + patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file), + patch("builtins.input", side_effect=["mykey", "myid"]), + ): + cfg = interactive_setup() + assert cfg.steam_api_key == "mykey" + assert cfg.steam_id == "myid" + assert config_file.exists() + + def test_empty_api_key_exits(self) -> None: + """Test empty api key exits.""" + with ( + patch("builtins.input", return_value=""), + pytest.raises(SystemExit), + ): + interactive_setup() + + def test_empty_steam_id_exits(self, tmp_path: Path) -> None: + """Test empty steam id exits.""" + config_dir = tmp_path / "cfg" + config_file = config_dir / "config.json" + with ( + patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir), + patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file), + patch("builtins.input", side_effect=["key", ""]), + pytest.raises(SystemExit), + ): + interactive_setup() diff --git a/steam_backlog_enforcer/tests/test_hltb.py b/steam_backlog_enforcer/tests/test_hltb.py index cb33e75..363f8c7 100644 --- a/steam_backlog_enforcer/tests/test_hltb.py +++ b/steam_backlog_enforcer/tests/test_hltb.py @@ -4,16 +4,14 @@ from __future__ import annotations import asyncio import json -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch import aiohttp from steam_backlog_enforcer._hltb_search import ( _AuthInfo, - _build_search_payload, _get_hltb_search_url, - _pick_best_hltb_entry, _similarity, ) from steam_backlog_enforcer.hltb import ( @@ -30,6 +28,7 @@ class TestHltbCache: """Tests for HLTB cache I/O.""" def test_load_cache_exists(self, tmp_path: Path) -> None: + """Test load cache exists.""" cache_file = tmp_path / "hltb_cache.json" cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8") with patch("steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file): @@ -37,17 +36,20 @@ class TestHltbCache: assert result == {440: 10.5} def test_load_cache_missing(self, tmp_path: Path) -> None: + """Test load cache missing.""" cache_file = tmp_path / "nonexistent.json" with patch("steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file): assert load_hltb_cache() == {} def test_load_cache_corrupt(self, tmp_path: Path) -> None: + """Test load cache corrupt.""" cache_file = tmp_path / "hltb_cache.json" cache_file.write_text("not json", encoding="utf-8") with patch("steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file): assert load_hltb_cache() == {} def test_save_cache(self, tmp_path: Path) -> None: + """Test save cache.""" cache_file = tmp_path / "hltb_cache.json" with ( patch( @@ -60,6 +62,7 @@ class TestHltbCache: assert cache_file.exists() def test_save_cache_os_error(self, tmp_path: Path) -> None: + """Test save cache os error.""" with patch( "steam_backlog_enforcer._hltb_types._atomic_write", side_effect=OSError("disk full"), @@ -67,6 +70,7 @@ class TestHltbCache: save_hltb_cache({440: 10.5}) # Should not raise def test_save_cache_full_extras_skips_existing_read(self, tmp_path: Path) -> None: + """Test save cache full extras skips existing read.""" from steam_backlog_enforcer._hltb_types import _HLTBExtras cache_file = tmp_path / "hltb_cache.json" @@ -89,6 +93,7 @@ class TestGetHltbSearchUrl: """Tests for _get_hltb_search_url.""" def test_discovers_url(self) -> None: + """Test discovers url.""" mock_info = MagicMock() mock_info.search_url = "/api/search/abc" with patch("steam_backlog_enforcer._hltb_search.HTMLRequests") as mock_html: @@ -98,12 +103,14 @@ class TestGetHltbSearchUrl: assert url == "https://howlongtobeat.com/api/search/abc" def test_fallback_url(self) -> None: + """Test fallback url.""" with patch("steam_backlog_enforcer._hltb_search.HTMLRequests") as mock_html: mock_html.send_website_request_getcode.return_value = None url = _get_hltb_search_url() assert url == "https://howlongtobeat.com/api/finder" def test_first_returns_none_second_returns_info(self) -> None: + """Test first returns none second returns info.""" mock_info = MagicMock() mock_info.search_url = "/api/search/xyz" with patch("steam_backlog_enforcer._hltb_search.HTMLRequests") as mock_html: @@ -113,6 +120,7 @@ class TestGetHltbSearchUrl: assert url == "https://howlongtobeat.com/api/search/xyz" def test_exception_fallback(self) -> None: + """Test exception fallback.""" with patch("steam_backlog_enforcer._hltb_search.HTMLRequests") as mock_html: mock_html.send_website_request_getcode.side_effect = RuntimeError url = _get_hltb_search_url() @@ -123,6 +131,7 @@ class TestGetAuthInfo: """Tests for _get_auth_info.""" def test_success(self) -> None: + """Test success.""" mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.json = AsyncMock( @@ -140,6 +149,7 @@ class TestGetAuthInfo: assert result == _AuthInfo("abc123", "ign_x", "ff") def test_success_no_hp(self) -> None: + """Test success no hp.""" mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.json = AsyncMock(return_value={"token": "abc123"}) @@ -155,6 +165,7 @@ class TestGetAuthInfo: assert result == _AuthInfo("abc123") def test_no_token_key(self) -> None: + """Test no token key.""" mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.json = AsyncMock(return_value={"notoken": True}) @@ -170,6 +181,7 @@ class TestGetAuthInfo: assert result is None def test_non_200(self) -> None: + """Test non 200.""" mock_resp = AsyncMock() mock_resp.status = 500 mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) @@ -184,6 +196,7 @@ class TestGetAuthInfo: assert result is None def test_client_error(self) -> None: + """Test client error.""" mock_session = MagicMock() ctx = AsyncMock() ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError) @@ -200,248 +213,13 @@ class TestSimilarity: """Tests for _similarity.""" def test_identical(self) -> None: + """Test identical.""" assert _similarity("hello", "hello") == 1.0 def test_different(self) -> None: + """Test different.""" assert _similarity("abc", "xyz") < 0.5 def test_case_insensitive(self) -> None: + """Test case insensitive.""" assert _similarity("Hello", "hello") == 1.0 - - -class TestBuildSearchPayload: - """Tests for _build_search_payload.""" - - def test_returns_json(self) -> None: - payload = _build_search_payload("Half-Life 2") - data = json.loads(payload) - assert data["searchType"] == "games" - assert data["searchTerms"] == ["Half-Life", "2"] - - def test_with_auth(self) -> None: - auth = _AuthInfo("t", "ign_x", "ff") - payload = _build_search_payload("TF2", auth=auth) - data = json.loads(payload) - assert data["ign_x"] == "ff" - - def test_with_auth_no_hp_key(self) -> None: - auth = _AuthInfo("t") - payload = _build_search_payload("TF2", auth=auth) - data = json.loads(payload) - assert "" not in data - - -class TestPickBestHltbEntry: - """Tests for _pick_best_hltb_entry.""" - - def test_empty(self) -> None: - assert _pick_best_hltb_entry("game", []) is None - - def test_single(self) -> None: - entry: dict[str, Any] = {"game_name": "Game", "comp_100": 3600} - result = _pick_best_hltb_entry("Game", [(entry, 1.0)]) - assert result is not None - assert result[0]["game_name"] == "Game" - - def test_prefers_full_edition_colon(self) -> None: - demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800} - full: dict[str, Any] = { - "game_name": "FAITH: The Unholy Trinity", - "comp_100": 7200, - } - result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)]) - assert result is not None - assert result[0]["game_name"] == "FAITH: The Unholy Trinity" - - def test_prefers_full_edition_dash(self) -> None: - demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800} - full: dict[str, Any] = {"game_name": "FAITH - Complete", "comp_100": 7200} - result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)]) - assert result is not None - assert result[0]["game_name"] == "FAITH - Complete" - - def test_falls_back_to_highest_similarity(self) -> None: - a: dict[str, Any] = {"game_name": "ABC", "comp_100": 3600} - b: dict[str, Any] = {"game_name": "DEF", "comp_100": 7200} - result = _pick_best_hltb_entry("ABC", [(a, 0.9), (b, 0.7)]) - assert result is not None - assert result[1] == 0.9 - - def test_prefers_non_dlc_when_available(self) -> None: - base: dict[str, Any] = { - "game_name": "Helltaker", - "game_type": "game", - "comp_100": 6846, - } - dlc: dict[str, Any] = { - "game_name": "Helltaker - Bonus Chapter: Examtaker", - "game_type": "dlc", - "comp_100": 4075, - } - result = _pick_best_hltb_entry("Helltaker", [(dlc, 0.95), (base, 0.8)]) - assert result is not None - assert result[0]["game_type"] == "game" - - def test_skips_prologue_subset(self) -> None: - """A '- Prologue' entry should not beat the full game.""" - full: dict[str, Any] = { - "game_name": "A Space For The Unbound", - "comp_100": 45000, - } - prologue: dict[str, Any] = { - "game_name": "A Space for the Unbound - Prologue", - "comp_100": 1680, - } - result = _pick_best_hltb_entry( - "A Space for the Unbound", - [(prologue, 0.9), (full, 0.95)], - ) - assert result is not None - assert result[0]["game_name"] == "A Space For The Unbound" - - def test_skips_demo_subset(self) -> None: - """A ': Demo' entry should not beat the full game.""" - full: dict[str, Any] = {"game_name": "MyGame", "comp_100": 36000} - demo: dict[str, Any] = {"game_name": "MyGame: Demo", "comp_100": 1800} - result = _pick_best_hltb_entry("MyGame", [(demo, 0.9), (full, 1.0)]) - assert result is not None - assert result[0]["game_name"] == "MyGame" - - def test_still_prefers_full_edition_over_demo(self) -> None: - """A ': Full Edition' entry should still be preferred (not a subset).""" - short: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800} - full: dict[str, Any] = { - "game_name": "FAITH: The Unholy Trinity", - "comp_100": 7200, - } - result = _pick_best_hltb_entry("FAITH", [(short, 1.0), (full, 0.8)]) - assert result is not None - assert result[0]["game_name"] == "FAITH: The Unholy Trinity" - - def test_exact_match_beats_unrelated_subtitle(self) -> None: - """Exact name with more hours wins over an unrelated subtitle entry. - - 'Killing Floor: Toy Master' (1.2 h) must NOT beat 'Killing Floor' - (296 h) just because it starts with 'Killing Floor:'. - """ - base: dict[str, Any] = { - "game_name": "Killing Floor", - "comp_100": 1065600, # 296 h - } - spinoff: dict[str, Any] = { - "game_name": "Killing Floor: Toy Master", - "comp_100": 4320, # 1.2 h - } - result = _pick_best_hltb_entry("Killing Floor", [(spinoff, 0.7), (base, 1.0)]) - assert result is not None - assert result[0]["game_name"] == "Killing Floor" - - def test_alias_exact_match_beats_spinoff(self) -> None: - """Base game found via game_alias beats a spinoff with matching prefix. - - When HLTB renames a game (e.g. 'Needy Streamer Overload' -> - 'NEEDY GIRL OVERDOSE'), the old name lives in game_alias. A spinoff - like 'Needy Streamer Overload: Typing of The Net' must NOT be - preferred just because its game_name starts with the search term. - """ - base: dict[str, Any] = { - "game_name": "NEEDY GIRL OVERDOSE", - "game_alias": "Needy Streamer Overload", - "comp_100": 43200, # 12 h - } - spinoff: dict[str, Any] = { - "game_name": "Needy Streamer Overload: Typing of The Net", - "comp_100": 3600, # 1 h - } - result = _pick_best_hltb_entry( - "NEEDY STREAMER OVERLOAD", - [(spinoff, 0.7), (base, 0.9)], - ) - assert result is not None - assert result[0]["game_name"] == "NEEDY GIRL OVERDOSE" - - def test_exact_match_beats_different_subtitled_game(self) -> None: - """Exact 'Timberman' (26.5 h) must beat 'Timberman: The Big Adventure' (2 h). - - Unlike FAITH where the short name is a demo, here the short name - is the real full game and the subtitled entry is a different, shorter - game. The exact match should win because it has more hours. - """ - base: dict[str, Any] = { - "game_name": "Timberman", - "comp_100": 95400, # 26.5 h - } - other: dict[str, Any] = { - "game_name": "Timberman: The Big Adventure", - "comp_100": 7200, # 2 h - } - timberman_vs: dict[str, Any] = { - "game_name": "Timberman VS", - "comp_100": 23400, # 6.5 h - } - result = _pick_best_hltb_entry( - "Timberman", - [(other, 0.49), (timberman_vs, 0.86), (base, 1.0)], - ) - assert result is not None - assert result[0]["game_name"] == "Timberman" - - def test_exact_match_wins_even_when_extended_appears_first(self) -> None: - """Exact match wins regardless of candidate ordering.""" - base: dict[str, Any] = { - "game_name": "Timberman", - "comp_100": 95400, # 26.5 h - } - other: dict[str, Any] = { - "game_name": "Timberman: The Big Adventure", - "comp_100": 7200, # 2 h - } - # Extended entry appears first in the list. - result = _pick_best_hltb_entry( - "Timberman", - [(other, 0.49), (base, 1.0)], - ) - assert result is not None - assert result[0]["game_name"] == "Timberman" - - def test_exact_only_no_extended(self) -> None: - """Exact match returned when no extended entries exist at all.""" - exact: dict[str, Any] = { - "game_name": "Celeste", - "comp_100": 180000, # 50 h - } - unrelated: dict[str, Any] = { - "game_name": "Unrelated Game", - "comp_100": 7200, - } - result = _pick_best_hltb_entry( - "Celeste", - [(exact, 1.0), (unrelated, 0.6)], - ) - assert result is not None - assert result[0]["game_name"] == "Celeste" - - def test_no_exact_no_extended_falls_back(self) -> None: - """When no exact or extended match exists, fall to highest similarity.""" - a: dict[str, Any] = {"game_name": "FooBar", "comp_100": 3600} - b: dict[str, Any] = {"game_name": "FooBaz", "comp_100": 7200} - result = _pick_best_hltb_entry("Foo", [(a, 0.7), (b, 0.8)]) - assert result is not None - assert result[0]["game_name"] == "FooBaz" - - def test_extended_only_no_exact(self) -> None: - """Extended entry returned when no exact name match exists.""" - extended: dict[str, Any] = { - "game_name": "Neon: Ultimate Edition", - "comp_100": 36000, - } - unrelated: dict[str, Any] = { - "game_name": "Neon Lights", - "comp_100": 3600, - } - result = _pick_best_hltb_entry( - "Neon", - [(extended, 0.6), (unrelated, 0.7)], - ) - assert result is not None - assert result[0]["game_name"] == "Neon: Ultimate Edition" diff --git a/steam_backlog_enforcer/tests/test_hltb_build_search_payload.py b/steam_backlog_enforcer/tests/test_hltb_build_search_payload.py new file mode 100644 index 0000000..8402d21 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_build_search_payload.py @@ -0,0 +1,35 @@ +"""Tests for hltb module.""" + +from __future__ import annotations + +import json + +from steam_backlog_enforcer._hltb_search import ( + _AuthInfo, + _build_search_payload, +) + + +class TestBuildSearchPayload: + """Tests for _build_search_payload.""" + + def test_returns_json(self) -> None: + """Test returns json.""" + payload = _build_search_payload("Half-Life 2") + data = json.loads(payload) + assert data["searchType"] == "games" + assert data["searchTerms"] == ["Half-Life", "2"] + + def test_with_auth(self) -> None: + """Test with auth.""" + auth = _AuthInfo("t", "ign_x", "ff") + payload = _build_search_payload("TF2", auth=auth) + data = json.loads(payload) + assert data["ign_x"] == "ff" + + def test_with_auth_no_hp_key(self) -> None: + """Test with auth no hp key.""" + auth = _AuthInfo("t") + payload = _build_search_payload("TF2", auth=auth) + data = json.loads(payload) + assert "" not in data diff --git a/steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py b/steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py new file mode 100644 index 0000000..e004e83 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py @@ -0,0 +1,128 @@ +"""Tests for hltb module — part 2 (missing coverage).""" + +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +from typing_extensions import Self + +from steam_backlog_enforcer._hltb_search import _AuthInfo +from steam_backlog_enforcer.hltb import ( + _fetch_batch_confidence_only, + fetch_hltb_confidence, + fetch_hltb_confidence_cached, +) + +PKG = "steam_backlog_enforcer.hltb" + + +class _DummySession: + """Minimal async context manager used to mock aiohttp ClientSession.""" + + async def __aenter__(self) -> Self: + """Enter async context.""" + return self + + async def __aexit__(self, *_args: object) -> bool: + """Exit async context.""" + return False + + +class TestConfidenceHelpers: + """Coverage tests for confidence-fetch helpers.""" + + def test_fetch_batch_confidence_only_returns_empty_without_auth(self) -> None: + """Test fetch batch confidence only returns empty without auth.""" + with ( + patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()), + patch(f"{PKG}.aiohttp.TCPConnector"), + patch(f"{PKG}._get_hltb_search_url", return_value="https://example"), + patch(f"{PKG}._get_auth_info", return_value=None), + ): + result = asyncio.run( + _fetch_batch_confidence_only([(1, "Game")], {}, {}, None), + ) + assert result == [] + + def test_fetch_batch_confidence_only_handles_empty_hp_and_default_counts( + self, + ) -> None: + """Test fetch batch confidence only handles empty hp and default counts.""" + auth_token = str(1) + with ( + patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()), + patch(f"{PKG}.aiohttp.TCPConnector"), + patch(f"{PKG}._get_hltb_search_url", return_value="https://example"), + patch( + f"{PKG}._get_auth_info", + return_value=_AuthInfo(token=auth_token, hp_key="", hp_val=""), + ), + patch(f"{PKG}._search_one", side_effect=[None]) as mock_search, + ): + result = asyncio.run( + _fetch_batch_confidence_only( + games=[(1, "Game")], + cache={}, + polls={}, + progress_cb=None, + count_comp=None, + ), + ) + assert result == [] + mock_search.assert_called_once() + + def test_fetch_batch_confidence_only_with_hp_key_and_prepopulated_count_comp( + self, + ) -> None: + """Test fetch batch confidence only with hp key and prepopulated count comp.""" + auth_token = str(1) + with ( + patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()), + patch(f"{PKG}.aiohttp.TCPConnector"), + patch(f"{PKG}._get_hltb_search_url", return_value="https://example"), + patch( + f"{PKG}._get_auth_info", + return_value=_AuthInfo(token=auth_token, hp_key="hpk", hp_val="hpv"), + ), + patch(f"{PKG}._search_one", side_effect=[None]) as mock_search, + ): + result = asyncio.run( + _fetch_batch_confidence_only( + games=[(1, "Game")], + cache={}, + polls={}, + progress_cb=None, + count_comp={1: 42}, + ), + ) + assert result == [] + mock_search.assert_called_once() + + def test_fetch_hltb_confidence_initializes_optional_dicts(self) -> None: + """Test fetch hltb confidence initializes optional dicts.""" + with patch(f"{PKG}.asyncio.run", return_value=[]) as mock_run: + result = fetch_hltb_confidence([(1, "Game")]) + assert result == [] + mock_run.assert_called_once() + + def test_fetch_hltb_confidence_empty_games_returns_empty(self) -> None: + """Test fetch hltb confidence empty games returns empty.""" + with patch(f"{PKG}.asyncio.run") as mock_run: + result = fetch_hltb_confidence([]) + assert result == [] + mock_run.assert_not_called() + + def test_fetch_hltb_confidence_cached_all_cached_skips_fetch(self) -> None: + """Test fetch hltb confidence cached all cached skips fetch.""" + with ( + patch(f"{PKG}.load_hltb_cache", return_value={1: 12.0}), + patch(f"{PKG}.load_hltb_polls_cache", return_value={1: 30}), + patch(f"{PKG}.load_hltb_count_comp_cache", return_value={1: 200}), + patch(f"{PKG}.fetch_hltb_confidence") as mock_fetch, + patch(f"{PKG}.save_hltb_cache") as mock_save, + ): + result = fetch_hltb_confidence_cached([(1, "Game")]) + assert result == {1: 12.0} + mock_fetch.assert_not_called() + mock_save.assert_not_called() diff --git a/steam_backlog_enforcer/tests/test_hltb_detail.py b/steam_backlog_enforcer/tests/test_hltb_detail.py index 570c23b..e3f2693 100644 --- a/steam_backlog_enforcer/tests/test_hltb_detail.py +++ b/steam_backlog_enforcer/tests/test_hltb_detail.py @@ -3,9 +3,8 @@ from __future__ import annotations import asyncio -import json from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import aiohttp from typing_extensions import Self @@ -15,34 +14,52 @@ from steam_backlog_enforcer._hltb_detail import ( _as_positive_int, _collect_dlc_relationships, _extract_base_leisure_hours, - _extract_comp_100_avg_and_high, _extract_dlc_relationships, - _fetch_detail_one, _fetch_dlc_leisure_hours, - _fetch_leisure_times, - _process_game_detail, ) from steam_backlog_enforcer._hltb_types import ( - _SAVE_INTERVAL, HLTBResult, - _HLTBExtras, ) +class _FakeTextResponse: + """Async context manager mimicking aiohttp response for text.""" + + def __init__(self, status: int, text: str = "") -> None: + """Test init.""" + self.status = status + self._text = text + + async def __aenter__(self) -> Self: + """Test aenter.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Test aexit.""" + + async def text(self) -> str: + """Test text.""" + return self._text + + class TestInternalHelpers: """Tests for internal helper coverage.""" def test_as_positive_int_float(self) -> None: + """Test as positive int float.""" assert _as_positive_int(1.9) == 1 def test_as_positive_int_invalid_type(self) -> None: + """Test as positive int invalid type.""" assert not _as_positive_int(object()) def test_extract_base_leisure_non_dict_game(self) -> None: + """Test extract base leisure non dict game.""" data: dict[str, Any] = {"game": [123]} assert _extract_base_leisure_hours(data) == -1 def test_extract_base_leisure_platform_data_comp_high_is_max(self) -> None: + """Test extract base leisure platform data comp high is max.""" data: dict[str, Any] = { "game": [{"comp_100_h": 16063}], "platformData": [{"platform": "PC", "comp_high": 23760}], @@ -50,6 +67,7 @@ class TestInternalHelpers: assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2) def test_extract_base_leisure_h_field_exceeds_platform_comp_high(self) -> None: + """Test extract base leisure h field exceeds platform comp high.""" data: dict[str, Any] = { "game": [{"comp_100_h": 25000}], "platformData": [{"platform": "PC", "comp_high": 23760}], @@ -57,6 +75,7 @@ class TestInternalHelpers: assert _extract_base_leisure_hours(data) == round(25000 / 3600, 2) def test_extract_base_leisure_max_of_multiple_platforms(self) -> None: + """Test extract base leisure max of multiple platforms.""" data: dict[str, Any] = { "game": [{}], "platformData": [ @@ -67,6 +86,7 @@ class TestInternalHelpers: assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2) def test_extract_base_leisure_platform_data_not_list(self) -> None: + """Test extract base leisure platform data not list.""" data: dict[str, Any] = { "game": [{"comp_100_h": 16063}], "platformData": "not_a_list", @@ -74,6 +94,7 @@ class TestInternalHelpers: assert _extract_base_leisure_hours(data) == round(16063 / 3600, 2) def test_extract_base_leisure_platform_non_dict_entry_skipped(self) -> None: + """Test extract base leisure platform non dict entry skipped.""" data: dict[str, Any] = { "game": [{"comp_100_h": 16063}], "platformData": ["bad", {"platform": "PC", "comp_high": 23760}], @@ -81,6 +102,7 @@ class TestInternalHelpers: assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2) def test_extract_base_leisure_platform_comp_high_zero_skipped(self) -> None: + """Test extract base leisure platform comp high zero skipped.""" data: dict[str, Any] = { "game": [{"comp_100_h": 16063}], "platformData": [{"platform": "PC", "comp_high": 0}], @@ -88,6 +110,7 @@ class TestInternalHelpers: assert _extract_base_leisure_hours(data) == round(16063 / 3600, 2) def test_extract_base_leisure_max_of_h_fields(self) -> None: + """Test extract base leisure max of h fields.""" data: dict[str, Any] = { "game": [ { @@ -101,12 +124,14 @@ class TestInternalHelpers: assert _extract_base_leisure_hours(data) == round(17959 / 3600, 2) def test_extract_base_leisure_fallback_to_avg_comp_main(self) -> None: + """Test extract base leisure fallback to avg comp main.""" data: dict[str, Any] = { "game": [{"comp_main": 10800, "comp_plus": 0, "comp_100": 0}], } assert _extract_base_leisure_hours(data) == round(10800 / 3600, 2) def test_extract_dlc_relationships_skips_non_dict(self) -> None: + """Test extract dlc relationships skips non dict.""" data: dict[str, Any] = { "relationships": [ "bad", @@ -116,6 +141,7 @@ class TestInternalHelpers: assert _extract_dlc_relationships(data) == [(7, 1.0)] def test_collect_dlc_relationships_ignores_non_positive_id(self) -> None: + """Test collect dlc relationships ignores non positive id.""" valid = [ HLTBResult( app_id=1, @@ -137,6 +163,7 @@ class TestInternalHelpers: assert ids == [] def test_apply_dlc_leisure_overrides(self) -> None: + """Test apply dlc leisure overrides.""" adjusted = _apply_dlc_leisure_overrides( base_hours=6.0, dlc_rels=[(10, 1.0), (11, 2.0)], @@ -145,14 +172,20 @@ class TestInternalHelpers: assert adjusted == 8.0 def test_fetch_dlc_leisure_hours_empty(self) -> None: + """Test fetch dlc leisure hours empty.""" + async def _run() -> dict[int, float]: + """Test run.""" async with aiohttp.ClientSession() as session: return await _fetch_dlc_leisure_hours(asyncio.Semaphore(1), session, []) assert asyncio.run(_run()) == {} def test_fetch_dlc_leisure_hours_skips_none_data(self) -> None: + """Test fetch dlc leisure hours skips none data.""" + async def _run() -> dict[int, float]: + """Test run.""" async with aiohttp.ClientSession() as session: with patch( "steam_backlog_enforcer._hltb_detail._fetch_detail_one", @@ -168,12 +201,14 @@ class TestInternalHelpers: assert asyncio.run(_run()) == {} def test_fetch_dlc_leisure_hours_skips_non_positive_leisure(self) -> None: + """Test fetch dlc leisure hours skips non positive leisure.""" bad_dlc_data: dict[str, Any] = { "game": [{"comp_100_h": 0, "comp_100": 0}], "relationships": [], } async def _run() -> dict[int, float]: + """Test run.""" async with aiohttp.ClientSession() as session: with patch( "steam_backlog_enforcer._hltb_detail._fetch_detail_one", @@ -187,406 +222,3 @@ class TestInternalHelpers: ) assert asyncio.run(_run()) == {} - - -class TestExtractComp100AvgAndHigh: - """Tests for _extract_comp_100_avg_and_high.""" - - def test_returns_minus_one_for_empty_game_list(self) -> None: - assert _extract_comp_100_avg_and_high({"game": []}) == (-1, -1) - - def test_returns_minus_one_for_non_list_game(self) -> None: - assert _extract_comp_100_avg_and_high({"game": "bad"}) == (-1, -1) - - def test_returns_minus_one_when_game0_not_dict(self) -> None: - assert _extract_comp_100_avg_and_high({"game": [42]}) == (-1, -1) - - def test_returns_avg_and_high(self) -> None: - data: dict[str, Any] = {"game": [{"comp_100": 7200, "comp_100_h": 10800}]} - avg_h, high_h = _extract_comp_100_avg_and_high(data) - assert avg_h == round(7200 / 3600, 2) - assert high_h == round(10800 / 3600, 2) - - def test_high_falls_back_to_avg_when_zero(self) -> None: - data: dict[str, Any] = {"game": [{"comp_100": 7200, "comp_100_h": 0}]} - avg_h, high_h = _extract_comp_100_avg_and_high(data) - assert avg_h == round(7200 / 3600, 2) - assert high_h == avg_h - - def test_avg_zero_returns_minus_one_avg(self) -> None: - data: dict[str, Any] = {"game": [{"comp_100": 0, "comp_100_h": 0}]} - avg_h, high_h = _extract_comp_100_avg_and_high(data) - assert avg_h == -1 - assert high_h == -1 - - -class TestProcessGameDetail: - """Tests for _process_game_detail.""" - - def test_returns_leisure_rush_and_l100(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 10800, "comp_100": 7200}], - "relationships": [], - } - leisure, rush_h, l100 = _process_game_detail(data, [], {}) - assert leisure == round(10800 / 3600, 2) - assert rush_h == round(7200 / 3600, 2) - assert l100 == round(10800 / 3600, 2) - - def test_negative_leisure_when_no_data(self) -> None: - leisure, rush_h, l100 = _process_game_detail({"game": []}, [], {}) - assert leisure == -1 - assert rush_h == -1.0 - assert l100 == -1.0 - - def test_rush_includes_dlc_fallback(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100": 7200, "comp_100_h": 0}], - "relationships": [], - } - dlc_rels = [(99, 1.5)] - _leisure, rush_h, _l100 = _process_game_detail(data, dlc_rels, {}) - assert rush_h == round(7200 / 3600 + 1.5, 2) - - def test_l100_uses_dlc_override(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 10800, "comp_100": 7200}], - "relationships": [], - } - dlc_rels = [(77, 2.0)] - dlc_hours_by_id = {77: 3.0} - _leisure, _rush_h, l100 = _process_game_detail(data, dlc_rels, dlc_hours_by_id) - assert l100 == round(10800 / 3600 + (3.0 - 2.0), 2) - - -class _FakeTextResponse: - """Async context manager mimicking aiohttp response for text.""" - - def __init__(self, status: int, text: str = "") -> None: - self.status = status - self._text = text - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, *args: object) -> None: - pass - - async def text(self) -> str: - return self._text - - -class TestFetchDetailOne: - """Tests for _fetch_detail_one.""" - - def test_success(self) -> None: - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 21243}], - "relationships": [], - } - next_data = {"props": {"pageProps": {"game": {"data": game_data}}}} - html = ( - '" - ) - resp = _FakeTextResponse(200, html) - session = MagicMock() - session.get = MagicMock(return_value=resp) - result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) - assert result == game_data - - def test_non_200(self) -> None: - resp = _FakeTextResponse(404) - session = MagicMock() - session.get = MagicMock(return_value=resp) - result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) - assert result is None - - def test_client_error(self) -> None: - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError) - ctx.__aexit__ = AsyncMock(return_value=False) - session = MagicMock() - session.get = MagicMock(return_value=ctx) - result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) - assert result is None - - def test_parse_failure(self) -> None: - resp = _FakeTextResponse(200, "no script") - session = MagicMock() - session.get = MagicMock(return_value=resp) - result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) - assert result is None - - -class TestFetchLeisureTimes: - """Tests for _fetch_leisure_times.""" - - def test_updates_cache(self) -> None: - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 21243}], - "relationships": [], - } - cache: dict[int, float] = {} - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - assert cache[440] == round(21243 / 3600, 2) - assert results[0].completionist_hours == round(21243 / 3600, 2) - - def test_no_valid_results(self) -> None: - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=0, - ), - ] - cache: dict[int, float] = {} - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - assert not cache - - def test_empty_results(self) -> None: - cache: dict[int, float] = {} - asyncio.run(_fetch_leisure_times([], cache, {}, None)) - assert not cache - - def test_detail_returns_none(self) -> None: - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - cache: dict[int, float] = {} - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=None, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - assert not cache - assert results[0].completionist_hours == 50.0 - - def test_negative_leisure(self) -> None: - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - game_data: dict[str, Any] = {"game": [], "relationships": []} - cache: dict[int, float] = {} - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - assert not cache - assert results[0].completionist_hours == 50.0 - - def test_with_progress_cb(self) -> None: - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": [], - } - cache: dict[int, float] = {} - cb = MagicMock() - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, cb)) - cb.assert_called_once() - - def test_save_interval(self) -> None: - """Trigger the _SAVE_INTERVAL branch in leisure fetching.""" - results = [ - HLTBResult( - app_id=i, - game_name=f"Game{i}", - completionist_hours=1.0, - similarity=1.0, - hltb_game_id=i + 1000, - ) - for i in range(_SAVE_INTERVAL) - ] - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": [], - } - cache: dict[int, float] = {} - with ( - patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ), - patch("steam_backlog_enforcer._hltb_detail.save_hltb_cache") as mock_save, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - mock_save.assert_called_once() - - def test_dlc_detail_overrides_relationship_fallback(self) -> None: - results = [ - HLTBResult( - app_id=1289310, - game_name="Helltaker", - completionist_hours=1.0, - similarity=1.0, - hltb_game_id=78118, - ), - ] - base_data: dict[str, Any] = { - "game": [{"comp_100_h": 21243, "comp_100": 6846}], - "relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}], - } - dlc_data: dict[str, Any] = { - "game": [{"comp_100_h": 12298, "comp_100": 4075}], - "relationships": [], - } - cache: dict[int, float] = {} - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - side_effect=[base_data, dlc_data], - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - - expected = round((21243 + 12298) / 3600, 2) - assert cache[1289310] == expected - assert results[0].completionist_hours == expected - - def test_missing_dlc_detail_keeps_relationship_fallback(self) -> None: - results = [ - HLTBResult( - app_id=1289310, - game_name="Helltaker", - completionist_hours=1.0, - similarity=1.0, - hltb_game_id=78118, - ), - ] - base_data: dict[str, Any] = { - "game": [{"comp_100_h": 21243, "comp_100": 6846}], - "relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}], - } - cache: dict[int, float] = {} - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - side_effect=[base_data, None], - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - - expected = round((21243 + 4075) / 3600, 2) - assert cache[1289310] == expected - assert results[0].completionist_hours == expected - - def test_extras_populated_with_rush_and_l100(self) -> None: - """rush_h and l100 are stored in extras when game has comp_100 data.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 10800, "comp_100": 7200}], - "relationships": [], - } - cache: dict[int, float] = {} - extras = _HLTBExtras(count_comp={440: 5}) - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras)) - assert extras.rush[440] == round(7200 / 3600, 2) - assert extras.leisure_100h[440] == round(10800 / 3600, 2) - - def test_with_explicit_extras(self) -> None: - """Pass a pre-populated _HLTBExtras to cover the non-None extras branch.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": [], - } - cache: dict[int, float] = {} - extras = _HLTBExtras(count_comp={440: 5}) - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras)) - assert cache[440] == 1.0 - - def test_count_comp_from_detail_page_stored_in_extras(self) -> None: - """Line 254: extras.count_comp updated when game detail has count_comp > 0.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 3600, "count_comp": 99}], - "relationships": [], - } - cache: dict[int, float] = {} - extras = _HLTBExtras() - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras)) - assert extras.count_comp[440] == 99 diff --git a/steam_backlog_enforcer/tests/test_hltb_detail_extract_comp100_avg_and_high.py b/steam_backlog_enforcer/tests/test_hltb_detail_extract_comp100_avg_and_high.py new file mode 100644 index 0000000..6c59e38 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_detail_extract_comp100_avg_and_high.py @@ -0,0 +1,68 @@ +"""Tests for HLTB internal helpers, detail fetching, and leisure times — part 3.""" + +from __future__ import annotations + +from typing import Any + +from typing_extensions import Self + +from steam_backlog_enforcer._hltb_detail import ( + _extract_comp_100_avg_and_high, +) + + +class _FakeTextResponse: + """Async context manager mimicking aiohttp response for text.""" + + def __init__(self, status: int, text: str = "") -> None: + """Test init.""" + self.status = status + self._text = text + + async def __aenter__(self) -> Self: + """Test aenter.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Test aexit.""" + + async def text(self) -> str: + """Test text.""" + return self._text + + +class TestExtractComp100AvgAndHigh: + """Tests for _extract_comp_100_avg_and_high.""" + + def test_returns_minus_one_for_empty_game_list(self) -> None: + """Test returns minus one for empty game list.""" + assert _extract_comp_100_avg_and_high({"game": []}) == (-1, -1) + + def test_returns_minus_one_for_non_list_game(self) -> None: + """Test returns minus one for non list game.""" + assert _extract_comp_100_avg_and_high({"game": "bad"}) == (-1, -1) + + def test_returns_minus_one_when_game0_not_dict(self) -> None: + """Test returns minus one when game0 not dict.""" + assert _extract_comp_100_avg_and_high({"game": [42]}) == (-1, -1) + + def test_returns_avg_and_high(self) -> None: + """Test returns avg and high.""" + data: dict[str, Any] = {"game": [{"comp_100": 7200, "comp_100_h": 10800}]} + avg_h, high_h = _extract_comp_100_avg_and_high(data) + assert avg_h == round(7200 / 3600, 2) + assert high_h == round(10800 / 3600, 2) + + def test_high_falls_back_to_avg_when_zero(self) -> None: + """Test high falls back to avg when zero.""" + data: dict[str, Any] = {"game": [{"comp_100": 7200, "comp_100_h": 0}]} + avg_h, high_h = _extract_comp_100_avg_and_high(data) + assert avg_h == round(7200 / 3600, 2) + assert high_h == avg_h + + def test_avg_zero_returns_minus_one_avg(self) -> None: + """Test avg zero returns minus one avg.""" + data: dict[str, Any] = {"game": [{"comp_100": 0, "comp_100_h": 0}]} + avg_h, high_h = _extract_comp_100_avg_and_high(data) + assert avg_h == -1 + assert high_h == -1 diff --git a/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times.py b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times.py new file mode 100644 index 0000000..e05ad78 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times.py @@ -0,0 +1,154 @@ +"""Tests for HLTB internal helpers, detail fetching, and leisure times — part 3.""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from typing_extensions import Self + +from steam_backlog_enforcer._hltb_detail import ( + _fetch_leisure_times, +) +from steam_backlog_enforcer._hltb_types import ( + HLTBResult, +) + + +class _FakeTextResponse: + """Async context manager mimicking aiohttp response for text.""" + + def __init__(self, status: int, text: str = "") -> None: + """Test init.""" + self.status = status + self._text = text + + async def __aenter__(self) -> Self: + """Test aenter.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Test aexit.""" + + async def text(self) -> str: + """Test text.""" + return self._text + + +class TestFetchLeisureTimes: + """Tests for _fetch_leisure_times.""" + + def test_updates_cache(self) -> None: + """Test updates cache.""" + results = [ + HLTBResult( + app_id=440, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=12345, + ), + ] + game_data: dict[str, Any] = { + "game": [{"comp_100_h": 21243}], + "relationships": [], + } + cache: dict[int, float] = {} + with patch( + "steam_backlog_enforcer._hltb_detail._fetch_detail_one", + new_callable=AsyncMock, + return_value=game_data, + ): + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + assert cache[440] == round(21243 / 3600, 2) + assert results[0].completionist_hours == round(21243 / 3600, 2) + + def test_no_valid_results(self) -> None: + """Test no valid results.""" + results = [ + HLTBResult( + app_id=440, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=0, + ), + ] + cache: dict[int, float] = {} + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + assert not cache + + def test_empty_results(self) -> None: + """Test empty results.""" + cache: dict[int, float] = {} + asyncio.run(_fetch_leisure_times([], cache, {}, None)) + assert not cache + + def test_detail_returns_none(self) -> None: + """Test detail returns none.""" + results = [ + HLTBResult( + app_id=440, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=12345, + ), + ] + cache: dict[int, float] = {} + with patch( + "steam_backlog_enforcer._hltb_detail._fetch_detail_one", + new_callable=AsyncMock, + return_value=None, + ): + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + assert not cache + assert results[0].completionist_hours == 50.0 + + def test_negative_leisure(self) -> None: + """Test negative leisure.""" + results = [ + HLTBResult( + app_id=440, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=12345, + ), + ] + game_data: dict[str, Any] = {"game": [], "relationships": []} + cache: dict[int, float] = {} + with patch( + "steam_backlog_enforcer._hltb_detail._fetch_detail_one", + new_callable=AsyncMock, + return_value=game_data, + ): + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + assert not cache + assert results[0].completionist_hours == 50.0 + + def test_with_progress_cb(self) -> None: + """Test with progress cb.""" + results = [ + HLTBResult( + app_id=440, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=12345, + ), + ] + game_data: dict[str, Any] = { + "game": [{"comp_100_h": 3600}], + "relationships": [], + } + cache: dict[int, float] = {} + cb = MagicMock() + with patch( + "steam_backlog_enforcer._hltb_detail._fetch_detail_one", + new_callable=AsyncMock, + return_value=game_data, + ): + asyncio.run(_fetch_leisure_times(results, cache, {}, cb)) + cb.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times_save_interval.py b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times_save_interval.py new file mode 100644 index 0000000..d30a0b8 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times_save_interval.py @@ -0,0 +1,204 @@ +"""Tests for HLTB internal helpers, detail fetching, and leisure times — part 3.""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import AsyncMock, patch + +from typing_extensions import Self + +from steam_backlog_enforcer._hltb_detail import ( + _fetch_leisure_times, +) +from steam_backlog_enforcer._hltb_types import ( + _SAVE_INTERVAL, + HLTBResult, + _HLTBExtras, +) + + +class _FakeTextResponse: + """Async context manager mimicking aiohttp response for text.""" + + def __init__(self, status: int, text: str = "") -> None: + """Test init.""" + self.status = status + self._text = text + + async def __aenter__(self) -> Self: + """Test aenter.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Test aexit.""" + + async def text(self) -> str: + """Test text.""" + return self._text + + +class TestFetchLeisureTimesGroup2: + """Tests for _fetch_leisure_times.""" + + def test_save_interval(self) -> None: + """Trigger the _SAVE_INTERVAL branch in leisure fetching.""" + results = [ + HLTBResult( + app_id=i, + game_name=f"Game{i}", + completionist_hours=1.0, + similarity=1.0, + hltb_game_id=i + 1000, + ) + for i in range(_SAVE_INTERVAL) + ] + game_data: dict[str, Any] = { + "game": [{"comp_100_h": 3600}], + "relationships": [], + } + cache: dict[int, float] = {} + with ( + patch( + "steam_backlog_enforcer._hltb_detail._fetch_detail_one", + new_callable=AsyncMock, + return_value=game_data, + ), + patch("steam_backlog_enforcer._hltb_detail.save_hltb_cache") as mock_save, + ): + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + mock_save.assert_called_once() + + def test_dlc_detail_overrides_relationship_fallback(self) -> None: + """Test dlc detail overrides relationship fallback.""" + results = [ + HLTBResult( + app_id=1289310, + game_name="Helltaker", + completionist_hours=1.0, + similarity=1.0, + hltb_game_id=78118, + ), + ] + base_data: dict[str, Any] = { + "game": [{"comp_100_h": 21243, "comp_100": 6846}], + "relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}], + } + dlc_data: dict[str, Any] = { + "game": [{"comp_100_h": 12298, "comp_100": 4075}], + "relationships": [], + } + cache: dict[int, float] = {} + with patch( + "steam_backlog_enforcer._hltb_detail._fetch_detail_one", + new_callable=AsyncMock, + side_effect=[base_data, dlc_data], + ): + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + + expected = round((21243 + 12298) / 3600, 2) + assert cache[1289310] == expected + assert results[0].completionist_hours == expected + + def test_missing_dlc_detail_keeps_relationship_fallback(self) -> None: + """Test missing dlc detail keeps relationship fallback.""" + results = [ + HLTBResult( + app_id=1289310, + game_name="Helltaker", + completionist_hours=1.0, + similarity=1.0, + hltb_game_id=78118, + ), + ] + base_data: dict[str, Any] = { + "game": [{"comp_100_h": 21243, "comp_100": 6846}], + "relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}], + } + cache: dict[int, float] = {} + with patch( + "steam_backlog_enforcer._hltb_detail._fetch_detail_one", + new_callable=AsyncMock, + side_effect=[base_data, None], + ): + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + + expected = round((21243 + 4075) / 3600, 2) + assert cache[1289310] == expected + assert results[0].completionist_hours == expected + + def test_extras_populated_with_rush_and_l100(self) -> None: + """rush_h and l100 are stored in extras when game has comp_100 data.""" + results = [ + HLTBResult( + app_id=440, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=12345, + ), + ] + game_data: dict[str, Any] = { + "game": [{"comp_100_h": 10800, "comp_100": 7200}], + "relationships": [], + } + cache: dict[int, float] = {} + extras = _HLTBExtras(count_comp={440: 5}) + with patch( + "steam_backlog_enforcer._hltb_detail._fetch_detail_one", + new_callable=AsyncMock, + return_value=game_data, + ): + asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras)) + assert extras.rush[440] == round(7200 / 3600, 2) + assert extras.leisure_100h[440] == round(10800 / 3600, 2) + + def test_with_explicit_extras(self) -> None: + """Pass a pre-populated _HLTBExtras to cover the non-None extras branch.""" + results = [ + HLTBResult( + app_id=440, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=12345, + ), + ] + game_data: dict[str, Any] = { + "game": [{"comp_100_h": 3600}], + "relationships": [], + } + cache: dict[int, float] = {} + extras = _HLTBExtras(count_comp={440: 5}) + with patch( + "steam_backlog_enforcer._hltb_detail._fetch_detail_one", + new_callable=AsyncMock, + return_value=game_data, + ): + asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras)) + assert cache[440] == 1.0 + + def test_count_comp_from_detail_page_stored_in_extras(self) -> None: + """Line 254: extras.count_comp updated when game detail has count_comp > 0.""" + results = [ + HLTBResult( + app_id=440, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=12345, + ), + ] + game_data: dict[str, Any] = { + "game": [{"comp_100_h": 3600, "count_comp": 99}], + "relationships": [], + } + cache: dict[int, float] = {} + extras = _HLTBExtras() + with patch( + "steam_backlog_enforcer._hltb_detail._fetch_detail_one", + new_callable=AsyncMock, + return_value=game_data, + ): + asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras)) + assert extras.count_comp[440] == 99 diff --git a/steam_backlog_enforcer/tests/test_hltb_detail_process_game_detail.py b/steam_backlog_enforcer/tests/test_hltb_detail_process_game_detail.py new file mode 100644 index 0000000..1e95c2f --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_detail_process_game_detail.py @@ -0,0 +1,127 @@ +"""Tests for HLTB internal helpers, detail fetching, and leisure times — part 3.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +from typing_extensions import Self + +from steam_backlog_enforcer._hltb_detail import ( + _fetch_detail_one, + _process_game_detail, +) + + +class _FakeTextResponse: + """Async context manager mimicking aiohttp response for text.""" + + def __init__(self, status: int, text: str = "") -> None: + """Test init.""" + self.status = status + self._text = text + + async def __aenter__(self) -> Self: + """Test aenter.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Test aexit.""" + + async def text(self) -> str: + """Test text.""" + return self._text + + +class TestProcessGameDetail: + """Tests for _process_game_detail.""" + + def test_returns_leisure_rush_and_l100(self) -> None: + """Test returns leisure rush and l100.""" + data: dict[str, Any] = { + "game": [{"comp_100_h": 10800, "comp_100": 7200}], + "relationships": [], + } + leisure, rush_h, l100 = _process_game_detail(data, [], {}) + assert leisure == round(10800 / 3600, 2) + assert rush_h == round(7200 / 3600, 2) + assert l100 == round(10800 / 3600, 2) + + def test_negative_leisure_when_no_data(self) -> None: + """Test negative leisure when no data.""" + leisure, rush_h, l100 = _process_game_detail({"game": []}, [], {}) + assert leisure == -1 + assert rush_h == -1.0 + assert l100 == -1.0 + + def test_rush_includes_dlc_fallback(self) -> None: + """Test rush includes dlc fallback.""" + data: dict[str, Any] = { + "game": [{"comp_100": 7200, "comp_100_h": 0}], + "relationships": [], + } + dlc_rels = [(99, 1.5)] + _leisure, rush_h, _l100 = _process_game_detail(data, dlc_rels, {}) + assert rush_h == round(7200 / 3600 + 1.5, 2) + + def test_l100_uses_dlc_override(self) -> None: + """Test l100 uses dlc override.""" + data: dict[str, Any] = { + "game": [{"comp_100_h": 10800, "comp_100": 7200}], + "relationships": [], + } + dlc_rels = [(77, 2.0)] + dlc_hours_by_id = {77: 3.0} + _leisure, _rush_h, l100 = _process_game_detail(data, dlc_rels, dlc_hours_by_id) + assert l100 == round(10800 / 3600 + (3.0 - 2.0), 2) + + +class TestFetchDetailOne: + """Tests for _fetch_detail_one.""" + + def test_success(self) -> None: + """Test success.""" + game_data: dict[str, Any] = { + "game": [{"comp_100_h": 21243}], + "relationships": [], + } + next_data = {"props": {"pageProps": {"game": {"data": game_data}}}} + html = ( + '" + ) + resp = _FakeTextResponse(200, html) + session = MagicMock() + session.get = MagicMock(return_value=resp) + result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) + assert result == game_data + + def test_non_200(self) -> None: + """Test non 200.""" + resp = _FakeTextResponse(404) + session = MagicMock() + session.get = MagicMock(return_value=resp) + result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) + assert result is None + + def test_client_error(self) -> None: + """Test client error.""" + ctx = AsyncMock() + ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError) + ctx.__aexit__ = AsyncMock(return_value=False) + session = MagicMock() + session.get = MagicMock(return_value=ctx) + result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) + assert result is None + + def test_parse_failure(self) -> None: + """Test parse failure.""" + resp = _FakeTextResponse(200, "no script") + session = MagicMock() + session.get = MagicMock(return_value=resp) + result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) + assert result is None diff --git a/steam_backlog_enforcer/tests/test_hltb_fetch_hltb_detail_missing.py b/steam_backlog_enforcer/tests/test_hltb_fetch_hltb_detail_missing.py new file mode 100644 index 0000000..b336127 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_fetch_hltb_detail_missing.py @@ -0,0 +1,176 @@ +"""Tests for hltb module — part 2 (missing coverage).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +from typing_extensions import Self + +from steam_backlog_enforcer.hltb import ( + fetch_hltb_detail_missing, +) + +if TYPE_CHECKING: + from steam_backlog_enforcer._hltb_types import _HLTBExtras + +PKG = "steam_backlog_enforcer.hltb" + + +class _DummySession: + """Minimal async context manager used to mock aiohttp ClientSession.""" + + async def __aenter__(self) -> Self: + """Enter async context.""" + return self + + async def __aexit__(self, *_args: object) -> bool: + """Exit async context.""" + return False + + +class TestFetchHltbDetailMissing: + """Tests for fetch_hltb_detail_missing.""" + + def test_no_missing_returns_zero(self) -> None: + """All games in rush cache with known game IDs → early return.""" + with ( + patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}), + patch(f"{PKG}.load_hltb_game_id_cache", return_value={440: 12345}), + patch(f"{PKG}.fetch_hltb_times") as mock_fetch, + ): + result = fetch_hltb_detail_missing([(440, "TF2")]) + assert result == 0 + mock_fetch.assert_not_called() + + def test_fetches_missing_and_returns_count(self) -> None: + """Games not in rush cache are fetched; returns count with rush data.""" + + def add_rush( + _games: object, + cache: dict[int, float] | None = None, + polls: dict[int, int] | None = None, + progress_cb: object = None, + extras: _HLTBExtras | None = None, + ) -> list[object]: + """Test add rush.""" + if extras is not None: + extras.rush[730] = 10.0 + if cache is not None: + cache[730] = 25.0 + return [] + + with ( + patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}), + patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}), + patch(f"{PKG}.load_hltb_polls_cache", return_value={}), + patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), + patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), + patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), + patch(f"{PKG}.fetch_hltb_times", side_effect=add_rush), + patch(f"{PKG}.save_hltb_cache") as mock_save, + patch(f"{PKG}.time.monotonic", side_effect=[0.0, 2.0]), + ): + result = fetch_hltb_detail_missing([(440, "TF2"), (730, "CS")]) + assert result == 1 + mock_save.assert_called_once() + + def test_restores_prior_hours_when_not_refound(self) -> None: + """Hours are restored when re-fetch finds nothing for the game.""" + saved: dict[int, float] = {} + + def capture_save( + cache: dict[int, float], + _polls: object, + _extras: object = None, + ) -> None: + """Test capture save.""" + saved.update(cache) + + with ( + patch(f"{PKG}.load_hltb_rush_cache", return_value={}), + patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}), + patch(f"{PKG}.load_hltb_polls_cache", return_value={}), + patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), + patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), + patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), + patch(f"{PKG}.fetch_hltb_times"), # no-op, cache stays empty + patch(f"{PKG}.save_hltb_cache", side_effect=capture_save), + patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]), + ): + fetch_hltb_detail_missing([(730, "CS")]) + assert saved[730] == 20.0 + + def test_does_not_restore_when_refound(self) -> None: + """Prior hours are NOT restored when re-fetch successfully finds game.""" + + def add_hours_and_rush( + _games: object, + cache: dict[int, float] | None = None, + polls: dict[int, int] | None = None, + progress_cb: object = None, + extras: _HLTBExtras | None = None, + ) -> list[object]: + """Test add hours and rush.""" + if cache is not None: + cache[730] = 30.0 + if extras is not None: + extras.rush[730] = 12.0 + return [] + + saved: dict[int, float] = {} + + def capture_save( + cache: dict[int, float], + _polls: object, + _extras: object = None, + ) -> None: + """Test capture save.""" + saved.update(cache) + + with ( + patch(f"{PKG}.load_hltb_rush_cache", return_value={}), + patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}), + patch(f"{PKG}.load_hltb_polls_cache", return_value={}), + patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), + patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), + patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), + patch(f"{PKG}.fetch_hltb_times", side_effect=add_hours_and_rush), + patch(f"{PKG}.save_hltb_cache", side_effect=capture_save), + patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]), + ): + result = fetch_hltb_detail_missing([(730, "CS")]) + assert result == 1 + assert saved[730] == 30.0 + + def test_zero_elapsed_rate(self) -> None: + """Covers the elapsed == 0 branch in the rate calculation.""" + with ( + patch(f"{PKG}.load_hltb_rush_cache", return_value={}), + patch(f"{PKG}.load_hltb_cache", return_value={}), + patch(f"{PKG}.load_hltb_polls_cache", return_value={}), + patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), + patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), + patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), + patch(f"{PKG}.fetch_hltb_times"), + patch(f"{PKG}.save_hltb_cache"), + patch(f"{PKG}.time.monotonic", side_effect=[5.0, 5.0]), + ): + result = fetch_hltb_detail_missing([(730, "CS")]) + assert result == 0 + + def test_id_only_missing_logs_else_branch(self) -> None: + """Rush data present but game ID missing → else branch in log selection.""" + with ( + patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}), + patch(f"{PKG}.load_hltb_cache", return_value={440: 15.0}), + patch(f"{PKG}.load_hltb_polls_cache", return_value={}), + patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), + patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), + patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), + patch(f"{PKG}.fetch_hltb_times"), + patch(f"{PKG}.save_hltb_cache"), + patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]), + ): + result = fetch_hltb_detail_missing([(440, "TF2")]) + assert result == 0 diff --git a/steam_backlog_enforcer/tests/test_hltb_part2.py b/steam_backlog_enforcer/tests/test_hltb_part2.py index b227df8..953156b 100644 --- a/steam_backlog_enforcer/tests/test_hltb_part2.py +++ b/steam_backlog_enforcer/tests/test_hltb_part2.py @@ -2,20 +2,14 @@ from __future__ import annotations -import asyncio from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch from typing_extensions import Self -from steam_backlog_enforcer._hltb_search import _AuthInfo from steam_backlog_enforcer.hltb import ( HLTB_BASE_URL, HLTBResult, - _fetch_batch_confidence_only, - fetch_hltb_confidence, - fetch_hltb_confidence_cached, - fetch_hltb_detail_missing, fetch_hltb_times_cached, get_hltb_submit_url, ) @@ -26,10 +20,23 @@ if TYPE_CHECKING: PKG = "steam_backlog_enforcer.hltb" +class _DummySession: + """Minimal async context manager used to mock aiohttp ClientSession.""" + + async def __aenter__(self) -> Self: + """Enter async context.""" + return self + + async def __aexit__(self, *_args: object) -> bool: + """Exit async context.""" + return False + + class TestFetchHltbTimesCached: """Tests for fetch_hltb_times_cached.""" def test_all_cached(self) -> None: + """Test all cached.""" with ( patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}), ): @@ -37,6 +44,7 @@ class TestFetchHltbTimesCached: assert result == {440: 50.0} def test_uncached_games_fetched(self) -> None: + """Test uncached games fetched.""" with ( patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}), patch(f"{PKG}.fetch_hltb_times") as mock_fetch, @@ -51,6 +59,7 @@ class TestFetchHltbTimesCached: progress_cb: object = None, extras: _HLTBExtras | None = None, ) -> list[object]: + """Test add to cache.""" if cache is not None: cache[730] = 20.0 if polls is not None: @@ -68,6 +77,7 @@ class TestFetchHltbTimesCached: mock_save.assert_called_once() def test_uncached_with_progress_cb(self) -> None: + """Test uncached with progress cb.""" cb = MagicMock() with ( patch(f"{PKG}.load_hltb_cache", return_value={}), @@ -109,6 +119,7 @@ class TestFetchHltbTimesCached: progress_cb: object = None, extras: _HLTBExtras | None = None, ) -> list[object]: + """Test add found.""" if cache is not None: cache[440] = 50.0 cache[730] = -1 @@ -132,6 +143,7 @@ class TestGetHltbSubmitUrl: """Tests for get_hltb_submit_url.""" def test_found(self) -> None: + """Test found.""" mock_result = HLTBResult( app_id=0, game_name="TF2", @@ -144,11 +156,13 @@ class TestGetHltbSubmitUrl: assert url == f"{HLTB_BASE_URL}/submit/game/12345" def test_not_found_empty(self) -> None: + """Test not found empty.""" with patch(f"{PKG}.fetch_hltb_times", return_value=[]): url = get_hltb_submit_url("Unknown Game") assert url is None def test_not_found_no_id(self) -> None: + """Test not found no id.""" mock_result = HLTBResult( app_id=0, game_name="TF2", @@ -159,251 +173,3 @@ class TestGetHltbSubmitUrl: with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]): url = get_hltb_submit_url("TF2") assert url is None - - -class _DummySession: - """Minimal async context manager used to mock aiohttp ClientSession.""" - - async def __aenter__(self) -> Self: - """Enter async context.""" - return self - - async def __aexit__(self, *_args: object) -> bool: - """Exit async context.""" - return False - - -class TestConfidenceHelpers: - """Coverage tests for confidence-fetch helpers.""" - - def test_fetch_batch_confidence_only_returns_empty_without_auth(self) -> None: - with ( - patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()), - patch(f"{PKG}.aiohttp.TCPConnector"), - patch(f"{PKG}._get_hltb_search_url", return_value="https://example"), - patch(f"{PKG}._get_auth_info", return_value=None), - ): - result = asyncio.run( - _fetch_batch_confidence_only([(1, "Game")], {}, {}, None), - ) - assert result == [] - - def test_fetch_batch_confidence_only_handles_empty_hp_and_default_counts( - self, - ) -> None: - auth_token = str(1) - with ( - patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()), - patch(f"{PKG}.aiohttp.TCPConnector"), - patch(f"{PKG}._get_hltb_search_url", return_value="https://example"), - patch( - f"{PKG}._get_auth_info", - return_value=_AuthInfo(token=auth_token, hp_key="", hp_val=""), - ), - patch(f"{PKG}._search_one", side_effect=[None]) as mock_search, - ): - result = asyncio.run( - _fetch_batch_confidence_only( - games=[(1, "Game")], - cache={}, - polls={}, - progress_cb=None, - count_comp=None, - ), - ) - assert result == [] - mock_search.assert_called_once() - - def test_fetch_batch_confidence_only_with_hp_key_and_prepopulated_count_comp( - self, - ) -> None: - auth_token = str(1) - with ( - patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()), - patch(f"{PKG}.aiohttp.TCPConnector"), - patch(f"{PKG}._get_hltb_search_url", return_value="https://example"), - patch( - f"{PKG}._get_auth_info", - return_value=_AuthInfo(token=auth_token, hp_key="hpk", hp_val="hpv"), - ), - patch(f"{PKG}._search_one", side_effect=[None]) as mock_search, - ): - result = asyncio.run( - _fetch_batch_confidence_only( - games=[(1, "Game")], - cache={}, - polls={}, - progress_cb=None, - count_comp={1: 42}, - ), - ) - assert result == [] - mock_search.assert_called_once() - - def test_fetch_hltb_confidence_initializes_optional_dicts(self) -> None: - with patch(f"{PKG}.asyncio.run", return_value=[]) as mock_run: - result = fetch_hltb_confidence([(1, "Game")]) - assert result == [] - mock_run.assert_called_once() - - def test_fetch_hltb_confidence_empty_games_returns_empty(self) -> None: - with patch(f"{PKG}.asyncio.run") as mock_run: - result = fetch_hltb_confidence([]) - assert result == [] - mock_run.assert_not_called() - - def test_fetch_hltb_confidence_cached_all_cached_skips_fetch(self) -> None: - with ( - patch(f"{PKG}.load_hltb_cache", return_value={1: 12.0}), - patch(f"{PKG}.load_hltb_polls_cache", return_value={1: 30}), - patch(f"{PKG}.load_hltb_count_comp_cache", return_value={1: 200}), - patch(f"{PKG}.fetch_hltb_confidence") as mock_fetch, - patch(f"{PKG}.save_hltb_cache") as mock_save, - ): - result = fetch_hltb_confidence_cached([(1, "Game")]) - assert result == {1: 12.0} - mock_fetch.assert_not_called() - mock_save.assert_not_called() - - -class TestFetchHltbDetailMissing: - """Tests for fetch_hltb_detail_missing.""" - - def test_no_missing_returns_zero(self) -> None: - """All games in rush cache with known game IDs → early return.""" - with ( - patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}), - patch(f"{PKG}.load_hltb_game_id_cache", return_value={440: 12345}), - patch(f"{PKG}.fetch_hltb_times") as mock_fetch, - ): - result = fetch_hltb_detail_missing([(440, "TF2")]) - assert result == 0 - mock_fetch.assert_not_called() - - def test_fetches_missing_and_returns_count(self) -> None: - """Games not in rush cache are fetched; returns count with rush data.""" - - def add_rush( - _games: object, - cache: dict[int, float] | None = None, - polls: dict[int, int] | None = None, - progress_cb: object = None, - extras: _HLTBExtras | None = None, - ) -> list[object]: - if extras is not None: - extras.rush[730] = 10.0 - if cache is not None: - cache[730] = 25.0 - return [] - - with ( - patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}), - patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}), - patch(f"{PKG}.load_hltb_polls_cache", return_value={}), - patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), - patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times", side_effect=add_rush), - patch(f"{PKG}.save_hltb_cache") as mock_save, - patch(f"{PKG}.time.monotonic", side_effect=[0.0, 2.0]), - ): - result = fetch_hltb_detail_missing([(440, "TF2"), (730, "CS")]) - assert result == 1 - mock_save.assert_called_once() - - def test_restores_prior_hours_when_not_refound(self) -> None: - """Hours are restored when re-fetch finds nothing for the game.""" - saved: dict[int, float] = {} - - def capture_save( - cache: dict[int, float], - _polls: object, - _extras: object = None, - ) -> None: - saved.update(cache) - - with ( - patch(f"{PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}), - patch(f"{PKG}.load_hltb_polls_cache", return_value={}), - patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), - patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times"), # no-op, cache stays empty - patch(f"{PKG}.save_hltb_cache", side_effect=capture_save), - patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]), - ): - fetch_hltb_detail_missing([(730, "CS")]) - assert saved[730] == 20.0 - - def test_does_not_restore_when_refound(self) -> None: - """Prior hours are NOT restored when re-fetch successfully finds game.""" - - def add_hours_and_rush( - _games: object, - cache: dict[int, float] | None = None, - polls: dict[int, int] | None = None, - progress_cb: object = None, - extras: _HLTBExtras | None = None, - ) -> list[object]: - if cache is not None: - cache[730] = 30.0 - if extras is not None: - extras.rush[730] = 12.0 - return [] - - saved: dict[int, float] = {} - - def capture_save( - cache: dict[int, float], - _polls: object, - _extras: object = None, - ) -> None: - saved.update(cache) - - with ( - patch(f"{PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}), - patch(f"{PKG}.load_hltb_polls_cache", return_value={}), - patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), - patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times", side_effect=add_hours_and_rush), - patch(f"{PKG}.save_hltb_cache", side_effect=capture_save), - patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]), - ): - result = fetch_hltb_detail_missing([(730, "CS")]) - assert result == 1 - assert saved[730] == 30.0 - - def test_zero_elapsed_rate(self) -> None: - """Covers the elapsed == 0 branch in the rate calculation.""" - with ( - patch(f"{PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{PKG}.load_hltb_cache", return_value={}), - patch(f"{PKG}.load_hltb_polls_cache", return_value={}), - patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), - patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times"), - patch(f"{PKG}.save_hltb_cache"), - patch(f"{PKG}.time.monotonic", side_effect=[5.0, 5.0]), - ): - result = fetch_hltb_detail_missing([(730, "CS")]) - assert result == 0 - - def test_id_only_missing_logs_else_branch(self) -> None: - """Rush data present but game ID missing → else branch in log selection.""" - with ( - patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}), - patch(f"{PKG}.load_hltb_cache", return_value={440: 15.0}), - patch(f"{PKG}.load_hltb_polls_cache", return_value={}), - patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), - patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times"), - patch(f"{PKG}.save_hltb_cache"), - patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]), - ): - result = fetch_hltb_detail_missing([(440, "TF2")]) - assert result == 0 diff --git a/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry.py b/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry.py new file mode 100644 index 0000000..306cfc5 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry.py @@ -0,0 +1,121 @@ +"""Tests for hltb module.""" + +from __future__ import annotations + +from typing import Any + +from steam_backlog_enforcer._hltb_search import ( + _pick_best_hltb_entry, +) + + +class TestPickBestHltbEntry: + """Tests for _pick_best_hltb_entry.""" + + def test_empty(self) -> None: + """Test empty.""" + assert _pick_best_hltb_entry("game", []) is None + + def test_single(self) -> None: + """Test single.""" + entry: dict[str, Any] = {"game_name": "Game", "comp_100": 3600} + result = _pick_best_hltb_entry("Game", [(entry, 1.0)]) + assert result is not None + assert result[0]["game_name"] == "Game" + + def test_prefers_full_edition_colon(self) -> None: + """Test prefers full edition colon.""" + demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800} + full: dict[str, Any] = { + "game_name": "FAITH: The Unholy Trinity", + "comp_100": 7200, + } + result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)]) + assert result is not None + assert result[0]["game_name"] == "FAITH: The Unholy Trinity" + + def test_prefers_full_edition_dash(self) -> None: + """Test prefers full edition dash.""" + demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800} + full: dict[str, Any] = {"game_name": "FAITH - Complete", "comp_100": 7200} + result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)]) + assert result is not None + assert result[0]["game_name"] == "FAITH - Complete" + + def test_falls_back_to_highest_similarity(self) -> None: + """Test falls back to highest similarity.""" + a: dict[str, Any] = {"game_name": "ABC", "comp_100": 3600} + b: dict[str, Any] = {"game_name": "DEF", "comp_100": 7200} + result = _pick_best_hltb_entry("ABC", [(a, 0.9), (b, 0.7)]) + assert result is not None + assert result[1] == 0.9 + + def test_prefers_non_dlc_when_available(self) -> None: + """Test prefers non dlc when available.""" + base: dict[str, Any] = { + "game_name": "Helltaker", + "game_type": "game", + "comp_100": 6846, + } + dlc: dict[str, Any] = { + "game_name": "Helltaker - Bonus Chapter: Examtaker", + "game_type": "dlc", + "comp_100": 4075, + } + result = _pick_best_hltb_entry("Helltaker", [(dlc, 0.95), (base, 0.8)]) + assert result is not None + assert result[0]["game_type"] == "game" + + def test_skips_prologue_subset(self) -> None: + """A '- Prologue' entry should not beat the full game.""" + full: dict[str, Any] = { + "game_name": "A Space For The Unbound", + "comp_100": 45000, + } + prologue: dict[str, Any] = { + "game_name": "A Space for the Unbound - Prologue", + "comp_100": 1680, + } + result = _pick_best_hltb_entry( + "A Space for the Unbound", + [(prologue, 0.9), (full, 0.95)], + ) + assert result is not None + assert result[0]["game_name"] == "A Space For The Unbound" + + def test_skips_demo_subset(self) -> None: + """A ': Demo' entry should not beat the full game.""" + full: dict[str, Any] = {"game_name": "MyGame", "comp_100": 36000} + demo: dict[str, Any] = {"game_name": "MyGame: Demo", "comp_100": 1800} + result = _pick_best_hltb_entry("MyGame", [(demo, 0.9), (full, 1.0)]) + assert result is not None + assert result[0]["game_name"] == "MyGame" + + def test_still_prefers_full_edition_over_demo(self) -> None: + """A ': Full Edition' entry should still be preferred (not a subset).""" + short: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800} + full: dict[str, Any] = { + "game_name": "FAITH: The Unholy Trinity", + "comp_100": 7200, + } + result = _pick_best_hltb_entry("FAITH", [(short, 1.0), (full, 0.8)]) + assert result is not None + assert result[0]["game_name"] == "FAITH: The Unholy Trinity" + + def test_exact_match_beats_unrelated_subtitle(self) -> None: + """Exact name with more hours wins over an unrelated subtitle entry. + + 'Killing Floor: Toy Master' (1.2 h) must NOT beat 'Killing Floor' + (296 h) just because it starts with 'Killing Floor:'. + """ + base: dict[str, Any] = { + "game_name": "Killing Floor", + "comp_100": 1065600, # 296 h + } + spinoff: dict[str, Any] = { + "game_name": "Killing Floor: Toy Master", + "comp_100": 4320, # 1.2 h + } + result = _pick_best_hltb_entry("Killing Floor", [(spinoff, 0.7), (base, 1.0)]) + assert result is not None + assert result[0]["game_name"] == "Killing Floor" diff --git a/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry_alias_exact_match_beats_spinoff.py b/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry_alias_exact_match_beats_spinoff.py new file mode 100644 index 0000000..98bacdb --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_pick_best_hltb_entry_alias_exact_match_beats_spinoff.py @@ -0,0 +1,123 @@ +"""Tests for hltb module.""" + +from __future__ import annotations + +from typing import Any + +from steam_backlog_enforcer._hltb_search import ( + _pick_best_hltb_entry, +) + + +class TestPickBestHltbEntryGroup2: + """Tests for _pick_best_hltb_entry.""" + + def test_alias_exact_match_beats_spinoff(self) -> None: + """Base game found via game_alias beats a spinoff with matching prefix. + + When HLTB renames a game (e.g. 'Needy Streamer Overload' -> + 'NEEDY GIRL OVERDOSE'), the old name lives in game_alias. A spinoff + like 'Needy Streamer Overload: Typing of The Net' must NOT be + preferred just because its game_name starts with the search term. + """ + base: dict[str, Any] = { + "game_name": "NEEDY GIRL OVERDOSE", + "game_alias": "Needy Streamer Overload", + "comp_100": 43200, # 12 h + } + spinoff: dict[str, Any] = { + "game_name": "Needy Streamer Overload: Typing of The Net", + "comp_100": 3600, # 1 h + } + result = _pick_best_hltb_entry( + "NEEDY STREAMER OVERLOAD", + [(spinoff, 0.7), (base, 0.9)], + ) + assert result is not None + assert result[0]["game_name"] == "NEEDY GIRL OVERDOSE" + + def test_exact_match_beats_different_subtitled_game(self) -> None: + """Exact 'Timberman' (26.5 h) must beat 'Timberman: The Big Adventure' (2 h). + + Unlike FAITH where the short name is a demo, here the short name + is the real full game and the subtitled entry is a different, shorter + game. The exact match should win because it has more hours. + """ + base: dict[str, Any] = { + "game_name": "Timberman", + "comp_100": 95400, # 26.5 h + } + other: dict[str, Any] = { + "game_name": "Timberman: The Big Adventure", + "comp_100": 7200, # 2 h + } + timberman_vs: dict[str, Any] = { + "game_name": "Timberman VS", + "comp_100": 23400, # 6.5 h + } + result = _pick_best_hltb_entry( + "Timberman", + [(other, 0.49), (timberman_vs, 0.86), (base, 1.0)], + ) + assert result is not None + assert result[0]["game_name"] == "Timberman" + + def test_exact_match_wins_even_when_extended_appears_first(self) -> None: + """Exact match wins regardless of candidate ordering.""" + base: dict[str, Any] = { + "game_name": "Timberman", + "comp_100": 95400, # 26.5 h + } + other: dict[str, Any] = { + "game_name": "Timberman: The Big Adventure", + "comp_100": 7200, # 2 h + } + # Extended entry appears first in the list. + result = _pick_best_hltb_entry( + "Timberman", + [(other, 0.49), (base, 1.0)], + ) + assert result is not None + assert result[0]["game_name"] == "Timberman" + + def test_exact_only_no_extended(self) -> None: + """Exact match returned when no extended entries exist at all.""" + exact: dict[str, Any] = { + "game_name": "Celeste", + "comp_100": 180000, # 50 h + } + unrelated: dict[str, Any] = { + "game_name": "Unrelated Game", + "comp_100": 7200, + } + result = _pick_best_hltb_entry( + "Celeste", + [(exact, 1.0), (unrelated, 0.6)], + ) + assert result is not None + assert result[0]["game_name"] == "Celeste" + + def test_no_exact_no_extended_falls_back(self) -> None: + """When no exact or extended match exists, fall to highest similarity.""" + a: dict[str, Any] = {"game_name": "FooBar", "comp_100": 3600} + b: dict[str, Any] = {"game_name": "FooBaz", "comp_100": 7200} + result = _pick_best_hltb_entry("Foo", [(a, 0.7), (b, 0.8)]) + assert result is not None + assert result[0]["game_name"] == "FooBaz" + + def test_extended_only_no_exact(self) -> None: + """Extended entry returned when no exact name match exists.""" + extended: dict[str, Any] = { + "game_name": "Neon: Ultimate Edition", + "comp_100": 36000, + } + unrelated: dict[str, Any] = { + "game_name": "Neon Lights", + "comp_100": 3600, + } + result = _pick_best_hltb_entry( + "Neon", + [(extended, 0.6), (unrelated, 0.7)], + ) + assert result is not None + assert result[0]["game_name"] == "Neon: Ultimate Edition" diff --git a/steam_backlog_enforcer/tests/test_hltb_search.py b/steam_backlog_enforcer/tests/test_hltb_search.py index a0aa2c2..02cbf8b 100644 --- a/steam_backlog_enforcer/tests/test_hltb_search.py +++ b/steam_backlog_enforcer/tests/test_hltb_search.py @@ -4,19 +4,15 @@ from __future__ import annotations import asyncio from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock import aiohttp from typing_extensions import Self from steam_backlog_enforcer._hltb_search import ( - _fetch_batch, _search_one, _SearchCtx, ) -from steam_backlog_enforcer._hltb_types import ( - _SAVE_INTERVAL, -) if TYPE_CHECKING: from collections.abc import Callable @@ -26,16 +22,19 @@ class _FakeResponse: """Async context manager mimicking aiohttp response.""" def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: + """Test init.""" self.status = status self._json_data = json_data or {} async def __aenter__(self) -> Self: + """Test aenter.""" return self async def __aexit__(self, *args: object) -> None: - pass + """Test aexit.""" async def json(self) -> dict[str, Any]: + """Test json.""" return self._json_data @@ -66,6 +65,7 @@ class TestSearchOne: """Tests for _search_one.""" def test_found(self) -> None: + """Test found.""" resp = _FakeResponse( 200, { @@ -105,6 +105,7 @@ class TestSearchOne: assert 440 not in ctx.hltb_game_id def test_not_found(self) -> None: + """Test not found.""" resp = _FakeResponse(200, {"data": []}) ctx = _make_ctx(_make_session(resp)) result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) @@ -112,6 +113,7 @@ class TestSearchOne: assert ctx.cache[440] == -1 def test_error(self) -> None: + """Test error.""" session = MagicMock() session.post.side_effect = aiohttp.ClientError("fail") ctx = _make_ctx(session) @@ -119,12 +121,14 @@ class TestSearchOne: assert result is None def test_non_200(self) -> None: + """Test non 200.""" resp = _FakeResponse(500) ctx = _make_ctx(_make_session(resp)) result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) assert result is None def test_fallback_name_without_year_suffix(self) -> None: + """Test fallback name without year suffix.""" session = MagicMock() session.post.side_effect = [ _FakeResponse(200, {"data": []}), @@ -156,6 +160,7 @@ class TestSearchOne: assert session.post.call_count == 2 def test_with_progress_cb(self) -> None: + """Test with progress cb.""" resp = _FakeResponse(200, {"data": []}) cb = MagicMock() ctx = _make_ctx(_make_session(resp), progress_cb=cb) @@ -163,6 +168,7 @@ class TestSearchOne: cb.assert_called_once() def test_low_similarity_skipped(self) -> None: + """Test low similarity skipped.""" resp = _FakeResponse( 200, { @@ -179,170 +185,3 @@ class TestSearchOne: ctx = _make_ctx(_make_session(resp)) result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) assert result is None - - def test_zero_comp_100_skipped(self) -> None: - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2", - "game_alias": "", - "comp_100": 0, - "game_id": 1, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is None - - def test_alias_match(self) -> None: - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "Team Fortress 2", - "game_alias": "TF2", - "comp_100": 180000, - "game_id": 12345, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is not None - - def test_full_edition_colon(self) -> None: - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2: Complete", - "game_alias": "", - "comp_100": 180000, - "game_id": 99, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is not None - - def test_full_edition_dash(self) -> None: - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2 - Complete", - "game_alias": "", - "comp_100": 180000, - "game_id": 99, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is not None - - def test_save_interval(self) -> None: - """Trigger the _SAVE_INTERVAL branch.""" - resp = _FakeResponse(200, {"data": []}) - ctx = _make_ctx(_make_session(resp)) - # Set done to one less than _SAVE_INTERVAL so it triggers save - - ctx.counter["done"] = _SAVE_INTERVAL - 1 - with patch("steam_backlog_enforcer._hltb_search.save_hltb_cache") as mock_save: - asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - mock_save.assert_called_once() - - def test_colon_strip_fallback_rejects_cross_franchise_match(self) -> None: - """Colon-stripped fallback must not match a different franchise loosely. - - "Vox Populi: Poland 2023" stripped to "Vox Populi" should NOT match - "Vox Populi Vox Dei 2" (different game, low-similarity entry). - """ - empty_resp = _FakeResponse(200, {"data": []}) - loose_resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "Vox Populi Vox Dei 2", - "game_alias": "", - "game_type": "game", - "comp_100": 14400, - "comp_100_count": 9, - "count_comp": 57, - "game_id": 99999, - } - ] - }, - ) - session = MagicMock() - session.post.side_effect = [empty_resp, loose_resp] - ctx = _make_ctx(session) - result = asyncio.run( - _search_one(asyncio.Semaphore(1), ctx, 2590810, "Vox Populi: Poland 2023") - ) - assert result is None - - def test_colon_strip_fallback_accepts_full_edition(self) -> None: - """Colon-stripped fallback must still match when the HLTB entry is a - full edition of the stripped name (name starts with stripped + ':'). - """ - empty_resp = _FakeResponse(200, {"data": []}) - full_edition_resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "Batman: Arkham Asylum", - "game_alias": "", - "game_type": "game", - "comp_100": 144000, - "comp_100_count": 300, - "count_comp": 5000, - "game_id": 11111, - } - ] - }, - ) - session = MagicMock() - session.post.side_effect = [empty_resp, full_edition_resp] - ctx = _make_ctx(session) - result = asyncio.run( - _search_one(asyncio.Semaphore(1), ctx, 35140, "Batman: Arkham Asylum") - ) - assert result is not None - assert result.game_name == "Batman: Arkham Asylum" - - -class TestFetchBatchHltb: - """Tests for _fetch_batch (the hltb version).""" - - def test_no_auth(self) -> None: - with ( - patch( - "steam_backlog_enforcer._hltb_search._get_hltb_search_url", - return_value="https://example.com", - ), - patch( - "steam_backlog_enforcer._hltb_search._get_auth_info", - new_callable=AsyncMock, - return_value=None, - ), - ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) - assert results == [] - - -class TestPickBestEntry: - """Tests for exact-vs-extended entry choice logic.""" diff --git a/steam_backlog_enforcer/tests/test_hltb_search_extract_leisure_hours.py b/steam_backlog_enforcer/tests/test_hltb_search_extract_leisure_hours.py new file mode 100644 index 0000000..ce9e10e --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_search_extract_leisure_hours.py @@ -0,0 +1,160 @@ +"""Tests for HLTB search entry picking, page parsing, and leisure extraction.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from typing_extensions import Self + +from steam_backlog_enforcer._hltb_detail import ( + _extract_leisure_hours, +) +from steam_backlog_enforcer._hltb_search import ( + _build_search_variants, +) + + +class _FakeResponse: + """Async context manager mimicking aiohttp response.""" + + def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: + """Test init.""" + self.status = status + self._json_data = json_data or {} + + async def __aenter__(self) -> Self: + """Test aenter.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Test aexit.""" + + async def json(self) -> dict[str, Any]: + """Test json.""" + return self._json_data + + +def _make_session(resp: _FakeResponse) -> MagicMock: + session = MagicMock() + session.post.return_value = resp + return session + + +class TestExtractLeisureHours: + """Tests for _extract_leisure_hours.""" + + def test_leisure_time_only(self) -> None: + """Test leisure time only.""" + data: dict[str, Any] = { + "game": [{"comp_100_h": 21243, "comp_100": 6800}], + "relationships": [], + } + assert _extract_leisure_hours(data) == round(21243 / 3600, 2) + + def test_leisure_with_dlc(self) -> None: + """Test leisure with dlc.""" + data: dict[str, Any] = { + "game": [{"comp_100_h": 21243, "comp_100": 6800}], + "relationships": [ + {"game_type": "dlc", "comp_100": 12298}, + {"game_type": "dlc", "comp_100": 3600}, + ], + } + assert _extract_leisure_hours(data) == round((21243 + 12298 + 3600) / 3600, 2) + + def test_fallback_to_comp_100(self) -> None: + """Test fallback to comp 100.""" + data: dict[str, Any] = { + "game": [{"comp_100": 7200}], + "relationships": [], + } + assert _extract_leisure_hours(data) == round(7200 / 3600, 2) + + def test_no_game_data(self) -> None: + """Test no game data.""" + assert _extract_leisure_hours({"game": [], "relationships": []}) == -1 + + def test_zero_leisure(self) -> None: + """Test zero leisure.""" + data: dict[str, Any] = { + "game": [{"comp_100_h": 0, "comp_100": 0}], + "relationships": [], + } + assert _extract_leisure_hours(data) == -1 + + def test_no_game_key(self) -> None: + """Test no game key.""" + assert _extract_leisure_hours({"relationships": []}) == -1 + + def test_non_dlc_relationship_ignored(self) -> None: + """Test non dlc relationship ignored.""" + data: dict[str, Any] = { + "game": [{"comp_100_h": 3600}], + "relationships": [ + {"game_type": "game", "comp_100": 9999}, + {"game_type": "dlc", "comp_100": 1800}, + ], + } + assert _extract_leisure_hours(data) == round((3600 + 1800) / 3600, 2) + + def test_dlc_zero_comp_100_skipped(self) -> None: + """Test dlc zero comp 100 skipped.""" + data: dict[str, Any] = { + "game": [{"comp_100_h": 3600}], + "relationships": [ + {"game_type": "dlc", "comp_100": 0}, + ], + } + assert _extract_leisure_hours(data) == round(3600 / 3600, 2) + + def test_negative_leisure(self) -> None: + """Test negative leisure.""" + data: dict[str, Any] = { + "game": [{"comp_100_h": -1, "comp_100": -1}], + "relationships": [], + } + assert _extract_leisure_hours(data) == -1 + + def test_string_numeric_fields(self) -> None: + """Test string numeric fields.""" + data: dict[str, Any] = { + "game": [{"comp_100_h": "7200", "comp_100": "3600"}], + "relationships": [{"game_type": "dlc", "game_id": "1", "comp_100": "1800"}], + } + assert _extract_leisure_hours(data) == round((7200 + 1800) / 3600, 2) + + def test_bad_string_falls_back_to_comp_100(self) -> None: + """Test bad string falls back to comp 100.""" + data: dict[str, Any] = { + "game": [{"comp_100_h": "bad", "comp_100": "3600"}], + "relationships": [], + } + assert _extract_leisure_hours(data) == 1.0 + + def test_relationships_not_list(self) -> None: + """Test relationships not list.""" + data: dict[str, Any] = { + "game": [{"comp_100_h": 3600}], + "relationships": "not-a-list", + } + assert _extract_leisure_hours(data) == 1.0 + + +class TestBuildSearchVariants: + """Tests for _build_search_variants.""" + + def test_subtitle_with_edition_strips_edition_from_subtitle_part(self) -> None: + # "Rocksmith 2014 Edition - Remastered" → no_subtitle = "Rocksmith 2014 Edition" + # (which != base), so lines 201-202 also add "Rocksmith" and "Rocksmith 2014" + """Test subtitle with edition strips edition from subtitle part.""" + variants = _build_search_variants("Rocksmith 2014 Edition - Remastered") + assert "Rocksmith 2014 Edition" in variants + assert "Rocksmith 2014" in variants + assert "Rocksmith" in variants + + def test_no_subtitle_skips_edition_strip(self) -> None: + # No " - " → no_subtitle == base → lines 201-202 are not executed + """Test no subtitle skips edition strip.""" + variants = _build_search_variants("Portal 2") + assert "Portal 2" in variants diff --git a/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py b/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py new file mode 100644 index 0000000..657b3e0 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py @@ -0,0 +1,84 @@ +"""Tests for HLTB search, batch-fetch, and page parsing — part 2.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock, patch + +from typing_extensions import Self + +from steam_backlog_enforcer._hltb_search import ( + _fetch_batch, + _SearchCtx, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + +class _FakeResponse: + """Async context manager mimicking aiohttp response.""" + + def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: + """Test init.""" + self.status = status + self._json_data = json_data or {} + + async def __aenter__(self) -> Self: + """Test aenter.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Test aexit.""" + + async def json(self) -> dict[str, Any]: + """Test json.""" + return self._json_data + + +def _make_session(resp: _FakeResponse) -> MagicMock: + session = MagicMock() + session.post.return_value = resp + return session + + +def _make_ctx( + session: MagicMock, + *, + cache: dict[int, float] | None = None, + progress_cb: Callable[..., object] | None = None, +) -> _SearchCtx: + return _SearchCtx( + session=session, + search_url="https://example.com/search", + headers={}, + cache=cache if cache is not None else {}, + counter={"done": 0, "found": 0}, + total=1, + progress_cb=progress_cb, + ) + + +class TestFetchBatchHltb: + """Tests for _fetch_batch (the hltb version).""" + + def test_no_auth(self) -> None: + """Test no auth.""" + with ( + patch( + "steam_backlog_enforcer._hltb_search._get_hltb_search_url", + return_value="https://example.com", + ), + patch( + "steam_backlog_enforcer._hltb_search._get_auth_info", + new_callable=AsyncMock, + return_value=None, + ), + ): + results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) + assert results == [] + + +class TestPickBestEntry: + """Tests for exact-vs-extended entry choice logic.""" diff --git a/steam_backlog_enforcer/tests/test_hltb_search_part2.py b/steam_backlog_enforcer/tests/test_hltb_search_part2.py index d890afa..3538ef7 100644 --- a/steam_backlog_enforcer/tests/test_hltb_search_part2.py +++ b/steam_backlog_enforcer/tests/test_hltb_search_part2.py @@ -10,11 +10,9 @@ from unittest.mock import AsyncMock, MagicMock, patch from typing_extensions import Self from steam_backlog_enforcer._hltb_detail import ( - _extract_leisure_hours, _parse_game_page, ) from steam_backlog_enforcer._hltb_search import ( - _build_search_variants, _fetch_batch, _pick_best_hltb_entry, ) @@ -28,16 +26,19 @@ class _FakeResponse: """Async context manager mimicking aiohttp response.""" def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: + """Test init.""" self.status = status self._json_data = json_data or {} async def __aenter__(self) -> Self: + """Test aenter.""" return self async def __aexit__(self, *args: object) -> None: - pass + """Test aexit.""" async def json(self) -> dict[str, Any]: + """Test json.""" return self._json_data @@ -51,6 +52,7 @@ class TestPickBestEntry: """Tests for exact-vs-extended entry choice logic.""" def test_prefers_exact_over_low_confidence_modded_extended(self) -> None: + """Test prefers exact over low confidence modded extended.""" exact = ( { "game_name": "Celeste", @@ -79,6 +81,7 @@ class TestPickBestEntry: assert best[0]["game_name"] == "Celeste" def test_prefers_extended_when_confident_and_longer(self) -> None: + """Test prefers extended when confident and longer.""" exact_demo = ( { "game_name": "FAITH", @@ -107,6 +110,7 @@ class TestPickBestEntry: assert best[0]["game_name"] == "FAITH: The Unholy Trinity" def test_with_auth(self) -> None: + """Test with auth.""" auth = _AuthInfo("token123", "ign_x", "ff") with ( patch( @@ -138,6 +142,7 @@ class TestPickBestEntry: assert len(results) == 1 def test_with_auth_no_hp(self) -> None: + """Test with auth no hp.""" auth = _AuthInfo("tok123") with ( patch( @@ -163,6 +168,7 @@ class TestPickBestEntry: assert results == [] def test_filters_none_results(self) -> None: + """Test filters none results.""" auth = _AuthInfo("tok123") with ( patch( @@ -192,6 +198,7 @@ class TestParseGamePage: """Tests for _parse_game_page.""" def test_valid_html(self) -> None: + """Test valid html.""" game_data: dict[str, Any] = { "game": [{"comp_100_h": 21243, "comp_100": 6800}], "relationships": [], @@ -207,119 +214,17 @@ class TestParseGamePage: assert _parse_game_page(html) == game_data def test_no_script_tag(self) -> None: + """Test no script tag.""" assert _parse_game_page("") is None def test_bad_json(self) -> None: + """Test bad json.""" html = '' assert _parse_game_page(html) is None def test_missing_keys(self) -> None: + """Test missing keys.""" html = ( '' ) assert _parse_game_page(html) is None - - -class TestExtractLeisureHours: - """Tests for _extract_leisure_hours.""" - - def test_leisure_time_only(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 21243, "comp_100": 6800}], - "relationships": [], - } - assert _extract_leisure_hours(data) == round(21243 / 3600, 2) - - def test_leisure_with_dlc(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 21243, "comp_100": 6800}], - "relationships": [ - {"game_type": "dlc", "comp_100": 12298}, - {"game_type": "dlc", "comp_100": 3600}, - ], - } - assert _extract_leisure_hours(data) == round((21243 + 12298 + 3600) / 3600, 2) - - def test_fallback_to_comp_100(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100": 7200}], - "relationships": [], - } - assert _extract_leisure_hours(data) == round(7200 / 3600, 2) - - def test_no_game_data(self) -> None: - assert _extract_leisure_hours({"game": [], "relationships": []}) == -1 - - def test_zero_leisure(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 0, "comp_100": 0}], - "relationships": [], - } - assert _extract_leisure_hours(data) == -1 - - def test_no_game_key(self) -> None: - assert _extract_leisure_hours({"relationships": []}) == -1 - - def test_non_dlc_relationship_ignored(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": [ - {"game_type": "game", "comp_100": 9999}, - {"game_type": "dlc", "comp_100": 1800}, - ], - } - assert _extract_leisure_hours(data) == round((3600 + 1800) / 3600, 2) - - def test_dlc_zero_comp_100_skipped(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": [ - {"game_type": "dlc", "comp_100": 0}, - ], - } - assert _extract_leisure_hours(data) == round(3600 / 3600, 2) - - def test_negative_leisure(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": -1, "comp_100": -1}], - "relationships": [], - } - assert _extract_leisure_hours(data) == -1 - - def test_string_numeric_fields(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": "7200", "comp_100": "3600"}], - "relationships": [{"game_type": "dlc", "game_id": "1", "comp_100": "1800"}], - } - assert _extract_leisure_hours(data) == round((7200 + 1800) / 3600, 2) - - def test_bad_string_falls_back_to_comp_100(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": "bad", "comp_100": "3600"}], - "relationships": [], - } - assert _extract_leisure_hours(data) == 1.0 - - def test_relationships_not_list(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": "not-a-list", - } - assert _extract_leisure_hours(data) == 1.0 - - -class TestBuildSearchVariants: - """Tests for _build_search_variants.""" - - def test_subtitle_with_edition_strips_edition_from_subtitle_part(self) -> None: - # "Rocksmith 2014 Edition - Remastered" → no_subtitle = "Rocksmith 2014 Edition" - # (which != base), so lines 201-202 also add "Rocksmith" and "Rocksmith 2014" - variants = _build_search_variants("Rocksmith 2014 Edition - Remastered") - assert "Rocksmith 2014 Edition" in variants - assert "Rocksmith 2014" in variants - assert "Rocksmith" in variants - - def test_no_subtitle_skips_edition_strip(self) -> None: - # No " - " → no_subtitle == base → lines 201-202 are not executed - variants = _build_search_variants("Portal 2") - assert "Portal 2" in variants diff --git a/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py b/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py new file mode 100644 index 0000000..f40c561 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py @@ -0,0 +1,215 @@ +"""Tests for HLTB search, batch-fetch, and page parsing — part 2.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +from typing_extensions import Self + +from steam_backlog_enforcer._hltb_search import ( + _search_one, + _SearchCtx, +) +from steam_backlog_enforcer._hltb_types import ( + _SAVE_INTERVAL, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + +class _FakeResponse: + """Async context manager mimicking aiohttp response.""" + + def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: + """Test init.""" + self.status = status + self._json_data = json_data or {} + + async def __aenter__(self) -> Self: + """Test aenter.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Test aexit.""" + + async def json(self) -> dict[str, Any]: + """Test json.""" + return self._json_data + + +def _make_session(resp: _FakeResponse) -> MagicMock: + session = MagicMock() + session.post.return_value = resp + return session + + +def _make_ctx( + session: MagicMock, + *, + cache: dict[int, float] | None = None, + progress_cb: Callable[..., object] | None = None, +) -> _SearchCtx: + return _SearchCtx( + session=session, + search_url="https://example.com/search", + headers={}, + cache=cache if cache is not None else {}, + counter={"done": 0, "found": 0}, + total=1, + progress_cb=progress_cb, + ) + + +class TestSearchOneGroup2: + """Tests for _search_one.""" + + def test_zero_comp_100_skipped(self) -> None: + """Test zero comp 100 skipped.""" + resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "TF2", + "game_alias": "", + "comp_100": 0, + "game_id": 1, + } + ], + }, + ) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is None + + def test_alias_match(self) -> None: + """Test alias match.""" + resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "Team Fortress 2", + "game_alias": "TF2", + "comp_100": 180000, + "game_id": 12345, + } + ], + }, + ) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is not None + + def test_full_edition_colon(self) -> None: + """Test full edition colon.""" + resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "TF2: Complete", + "game_alias": "", + "comp_100": 180000, + "game_id": 99, + } + ], + }, + ) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is not None + + def test_full_edition_dash(self) -> None: + """Test full edition dash.""" + resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "TF2 - Complete", + "game_alias": "", + "comp_100": 180000, + "game_id": 99, + } + ], + }, + ) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is not None + + def test_save_interval(self) -> None: + """Trigger the _SAVE_INTERVAL branch.""" + resp = _FakeResponse(200, {"data": []}) + ctx = _make_ctx(_make_session(resp)) + # Set done to one less than _SAVE_INTERVAL so it triggers save + + ctx.counter["done"] = _SAVE_INTERVAL - 1 + with patch("steam_backlog_enforcer._hltb_search.save_hltb_cache") as mock_save: + asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + mock_save.assert_called_once() + + def test_colon_strip_fallback_rejects_cross_franchise_match(self) -> None: + """Colon-stripped fallback must not match a different franchise loosely. + + "Vox Populi: Poland 2023" stripped to "Vox Populi" should NOT match + "Vox Populi Vox Dei 2" (different game, low-similarity entry). + """ + empty_resp = _FakeResponse(200, {"data": []}) + loose_resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "Vox Populi Vox Dei 2", + "game_alias": "", + "game_type": "game", + "comp_100": 14400, + "comp_100_count": 9, + "count_comp": 57, + "game_id": 99999, + } + ] + }, + ) + session = MagicMock() + session.post.side_effect = [empty_resp, loose_resp] + ctx = _make_ctx(session) + result = asyncio.run( + _search_one(asyncio.Semaphore(1), ctx, 2590810, "Vox Populi: Poland 2023") + ) + assert result is None + + def test_colon_strip_fallback_accepts_full_edition(self) -> None: + """Colon-stripped fallback must still match when the HLTB entry is a + full edition of the stripped name (name starts with stripped + ':'). + """ + empty_resp = _FakeResponse(200, {"data": []}) + full_edition_resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "Batman: Arkham Asylum", + "game_alias": "", + "game_type": "game", + "comp_100": 144000, + "comp_100_count": 300, + "count_comp": 5000, + "game_id": 11111, + } + ] + }, + ) + session = MagicMock() + session.post.side_effect = [empty_resp, full_edition_resp] + ctx = _make_ctx(session) + result = asyncio.run( + _search_one(asyncio.Semaphore(1), ctx, 35140, "Batman: Arkham Asylum") + ) + assert result is not None + assert result.game_name == "Batman: Arkham Asylum" diff --git a/steam_backlog_enforcer/tests/test_polls_tracking.py b/steam_backlog_enforcer/tests/test_polls_tracking.py index 19882d3..b9d9bd2 100644 --- a/steam_backlog_enforcer/tests/test_polls_tracking.py +++ b/steam_backlog_enforcer/tests/test_polls_tracking.py @@ -6,7 +6,6 @@ import json from typing import TYPE_CHECKING from unittest.mock import patch -from steam_backlog_enforcer import _cmd_done from steam_backlog_enforcer._hltb_types import ( HLTBResult, _HLTBExtras, @@ -27,10 +26,19 @@ _CMD = "steam_backlog_enforcer._cmd_done" _SCAN = "steam_backlog_enforcer.scanning" +def _state(finished: list[int], current: int | None = None) -> State: + s = State() + s.finished_app_ids = list(finished) + s.current_app_id = current + s.current_game_name = "" + return s + + class TestCacheSchema: """Tests for the new cache schema and back-compat migration.""" def test_legacy_float_migrates(self, tmp_path: Path) -> None: + """Test legacy float migrates.""" cache_file = tmp_path / "hltb_cache.json" cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8") with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): @@ -39,6 +47,7 @@ class TestCacheSchema: assert load_hltb_count_comp_cache() == {440: 0} def test_new_dict_schema(self, tmp_path: Path) -> None: + """Test new dict schema.""" cache_file = tmp_path / "hltb_cache.json" cache_file.write_text( json.dumps({"440": {"hours": 10.5, "polls": 7, "count_comp": 20}}), @@ -50,6 +59,7 @@ class TestCacheSchema: assert load_hltb_count_comp_cache() == {440: 20} def test_invalid_app_id_skipped(self, tmp_path: Path) -> None: + """Test invalid app id skipped.""" cache_file = tmp_path / "hltb_cache.json" cache_file.write_text( json.dumps({"notanint": 1.0, "440": 5.0}), encoding="utf-8" @@ -57,13 +67,15 @@ class TestCacheSchema: with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): assert load_hltb_cache() == {440: 5.0} - def test_unparseable_value_skipped(self, tmp_path: Path) -> None: + def test_unparsable_value_skipped(self, tmp_path: Path) -> None: + """Test unparsable value skipped.""" cache_file = tmp_path / "hltb_cache.json" cache_file.write_text(json.dumps({"440": "notafloat"}), encoding="utf-8") with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): assert load_hltb_cache() == {} def test_save_with_polls_roundtrip(self, tmp_path: Path) -> None: + """Test save with polls roundtrip.""" cache_file = tmp_path / "hltb_cache.json" with ( patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), @@ -83,6 +95,7 @@ class TestCacheSchema: } def test_save_without_polls_defaults_zero(self, tmp_path: Path) -> None: + """Test save without polls defaults zero.""" cache_file = tmp_path / "hltb_cache.json" with ( patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), @@ -113,12 +126,16 @@ class TestCacheSchema: class TestHltbResultPolls: + """Tests for Hltb Result Polls.""" + def test_default_zero(self) -> None: + """Test default zero.""" r = HLTBResult(app_id=1, game_name="x", completionist_hours=1.0, similarity=1) assert r.comp_100_count == 0 assert r.count_comp == 0 def test_explicit(self) -> None: + """Test explicit.""" r = HLTBResult( app_id=1, game_name="x", @@ -132,7 +149,10 @@ class TestHltbResultPolls: class TestGameInfoPolls: + """Tests for Game Info Polls.""" + def test_snapshot_roundtrip(self) -> None: + """Test snapshot roundtrip.""" g = GameInfo( app_id=1, name="X", @@ -150,6 +170,7 @@ class TestGameInfoPolls: assert restored.count_comp == 20 def test_snapshot_missing_field_defaults(self) -> None: + """Test snapshot missing field defaults.""" snap = { "app_id": 1, "name": "X", @@ -159,224 +180,3 @@ class TestGameInfoPolls: restored = GameInfo.from_snapshot(snap) assert restored.comp_100_count == 0 assert restored.count_comp == 0 - - -def _state(finished: list[int], current: int | None = None) -> State: - s = State() - s.finished_app_ids = list(finished) - s.current_app_id = current - s.current_game_name = "" - return s - - -class TestBackfillPollsForFinished: - def test_no_missing_returns_existing(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"1": {"hours": 1.0, "polls": 5}}), encoding="utf-8" - ) - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), - ): - result = _cmd_done._backfill_polls_for_finished(_state([1])) - assert result == {1: 5} - - def test_no_snapshot_no_missing(self) -> None: - with ( - patch(f"{_CMD}.load_hltb_polls_cache", return_value={}), - patch(f"{_CMD}.load_snapshot", return_value=None), - ): - assert _cmd_done._backfill_polls_for_finished(_state([1])) == {} - - def test_missing_triggers_fetch(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"1": {"hours": 2.0, "polls": 0}}), encoding="utf-8" - ) - - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - data = json.loads(cache_file.read_text(encoding="utf-8")) - for aid, _name in games: - data[str(aid)] = {"hours": 2.0, "polls": 9} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: 2.0 for aid, _ in games} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), - patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), - patch(f"{_CMD}._echo"), - ): - result = _cmd_done._backfill_polls_for_finished(_state([1])) - assert result == {1: 9} - - def test_extra_app_id_with_zero_polls_added(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"7": {"hours": 1.0, "polls": 0}}), encoding="utf-8" - ) - - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - data = json.loads(cache_file.read_text(encoding="utf-8")) - for aid, _name in games: - data[str(aid)] = {"hours": 1.0, "polls": 4} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: 1.0 for aid, _ in games} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 7, "name": "G"}]), - patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), - patch(f"{_CMD}._echo"), - ): - result = _cmd_done._backfill_polls_for_finished( - _state([], current=7), extra_app_id=7 - ) - assert result == {7: 4} - - def test_preserves_prior_hours_on_miss(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"3": {"hours": 4.0, "polls": 0}}), encoding="utf-8" - ) - - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - # Simulate a refetch returning a miss (hours -1, polls 0). - data = json.loads(cache_file.read_text(encoding="utf-8")) - for aid, _name in games: - data[str(aid)] = {"hours": -1, "polls": 0} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: -1 for aid, _ in games} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 3, "name": "G"}]), - patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), - patch(f"{_CMD}._echo"), - ): - _cmd_done._backfill_polls_for_finished(_state([3])) - # Prior hours should be preserved on miss. - final = json.loads(cache_file.read_text(encoding="utf-8")) - assert final["3"]["hours"] == 4.0 - - -class TestReportAssignedConfidence: - def test_new_low_warning(self) -> None: - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 1, 2: 5, 3: 10}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "OldShortest"}, - {"app_id": 3, "name": "Other"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2, 3], current=1)) - assert any("NEW LOW" in s for s in echoed) - assert any("Historical min" in s and "OldShortest" in s for s in echoed) - - def test_zero_polls_warning_with_history(self) -> None: - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 0, 2: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "Old"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2], current=1)) - assert any("no polls recorded" in s for s in echoed) - - def test_zero_polls_warning_no_history(self) -> None: - echoed: list[str] = [] - with ( - patch(f"{_CMD}._backfill_polls_for_finished", return_value={1: 0}), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([], current=1)) - assert any("no polls recorded" in s for s in echoed) - assert not any("Historical min" in s for s in echoed) - - def test_healthy_no_warning(self) -> None: - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 50, 2: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "Old"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2], current=1)) - assert not any("NEW LOW" in s for s in echoed) - assert not any("no polls recorded" in s for s in echoed) - assert any("HLTB confidence: 50" in s for s in echoed) - - def test_unknown_finished_uses_appid_label(self) -> None: - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 50, 99: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([99], current=1)) - assert any("AppID=99" in s for s in echoed) - - def test_chosen_equals_min_no_warning(self) -> None: - # Edge case: chosen_polls == min_polls (not a new low). - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 5, 2: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "Old"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2], current=1)) - assert not any("NEW LOW" in s for s in echoed) - assert not any("no polls recorded" in s for s in echoed) diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py b/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py new file mode 100644 index 0000000..8ae5726 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py @@ -0,0 +1,130 @@ +"""Tests for HLTB poll-count tracking, schema migration, and confidence display.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from steam_backlog_enforcer import _cmd_done +from steam_backlog_enforcer.config import State + +if TYPE_CHECKING: + from pathlib import Path + +_TYPES = "steam_backlog_enforcer._hltb_types" +_CMD = "steam_backlog_enforcer._cmd_done" +_SCAN = "steam_backlog_enforcer.scanning" + + +def _state(finished: list[int], current: int | None = None) -> State: + s = State() + s.finished_app_ids = list(finished) + s.current_app_id = current + s.current_game_name = "" + return s + + +class TestBackfillPollsForFinished: + """Tests for Backfill Polls For Finished.""" + + def test_no_missing_returns_existing(self, tmp_path: Path) -> None: + """Test no missing returns existing.""" + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"1": {"hours": 1.0, "polls": 5}}), encoding="utf-8" + ) + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), + ): + result = _cmd_done._backfill_polls_for_finished(_state([1])) + assert result == {1: 5} + + def test_no_snapshot_no_missing(self) -> None: + """Test no snapshot no missing.""" + with ( + patch(f"{_CMD}.load_hltb_polls_cache", return_value={}), + patch(f"{_CMD}.load_snapshot", return_value=None), + ): + assert _cmd_done._backfill_polls_for_finished(_state([1])) == {} + + def test_missing_triggers_fetch(self, tmp_path: Path) -> None: + """Test missing triggers fetch.""" + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"1": {"hours": 2.0, "polls": 0}}), encoding="utf-8" + ) + + def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + """Test fake fetch.""" + data = json.loads(cache_file.read_text(encoding="utf-8")) + for aid, _name in games: + data[str(aid)] = {"hours": 2.0, "polls": 9} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {aid: 2.0 for aid, _ in games} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), + patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + patch(f"{_CMD}._echo"), + ): + result = _cmd_done._backfill_polls_for_finished(_state([1])) + assert result == {1: 9} + + def test_extra_app_id_with_zero_polls_added(self, tmp_path: Path) -> None: + """Test extra app id with zero polls added.""" + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"7": {"hours": 1.0, "polls": 0}}), encoding="utf-8" + ) + + def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + """Test fake fetch.""" + data = json.loads(cache_file.read_text(encoding="utf-8")) + for aid, _name in games: + data[str(aid)] = {"hours": 1.0, "polls": 4} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {aid: 1.0 for aid, _ in games} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 7, "name": "G"}]), + patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + patch(f"{_CMD}._echo"), + ): + result = _cmd_done._backfill_polls_for_finished( + _state([], current=7), extra_app_id=7 + ) + assert result == {7: 4} + + def test_preserves_prior_hours_on_miss(self, tmp_path: Path) -> None: + """Test preserves prior hours on miss.""" + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"3": {"hours": 4.0, "polls": 0}}), encoding="utf-8" + ) + + def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + # Simulate a refetch returning a miss (hours -1, polls 0). + """Test fake fetch.""" + data = json.loads(cache_file.read_text(encoding="utf-8")) + for aid, _name in games: + data[str(aid)] = {"hours": -1, "polls": 0} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {aid: -1 for aid, _ in games} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 3, "name": "G"}]), + patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + patch(f"{_CMD}._echo"), + ): + _cmd_done._backfill_polls_for_finished(_state([3])) + # Prior hours should be preserved on miss. + final = json.loads(cache_file.read_text(encoding="utf-8")) + assert final["3"]["hours"] == 4.0 diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_part2.py b/steam_backlog_enforcer/tests/test_polls_tracking_part2.py index 6536b8e..cf6e5a3 100644 --- a/steam_backlog_enforcer/tests/test_polls_tracking_part2.py +++ b/steam_backlog_enforcer/tests/test_polls_tracking_part2.py @@ -4,9 +4,9 @@ from __future__ import annotations import json from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch +from unittest.mock import patch -from steam_backlog_enforcer import _cmd_done, _scanning_confidence, scanning +from steam_backlog_enforcer import _scanning_confidence, scanning from steam_backlog_enforcer.config import State from steam_backlog_enforcer.steam_api import GameInfo @@ -28,8 +28,11 @@ def _state(finished: list[int], current: int | None = None) -> State: class TestScanningPollsIntegration: + """Tests for Scanning Polls Integration.""" + def test_do_scan_kept_assignment_reports(self) -> None: # Targeted test for scanning's `else` branch that prints CURRENT. + """Test do scan kept assignment reports.""" echoed: list[str] = [] games = [ GameInfo( @@ -56,6 +59,7 @@ class TestScanningPollsIntegration: mock_report.assert_called_once() def test_report_poll_confidence_new_low(self) -> None: + """Test report poll confidence new low.""" echoed: list[str] = [] chosen = GameInfo( app_id=1, @@ -90,6 +94,7 @@ class TestScanningPollsIntegration: assert chosen.comp_100_count == 1 def test_report_poll_confidence_no_history(self) -> None: + """Test report poll confidence no history.""" echoed: list[str] = [] chosen = GameInfo( app_id=1, @@ -112,6 +117,7 @@ class TestScanningPollsIntegration: assert any("HLTB confidence: 4" in s for s in echoed) def test_scanning_backfill_no_missing(self, tmp_path: Path) -> None: + """Test scanning backfill no missing.""" cache_file = tmp_path / "hltb_cache.json" cache_file.write_text( json.dumps({"2": {"hours": 1.0, "polls": 5}}), encoding="utf-8" @@ -132,12 +138,14 @@ class TestScanningPollsIntegration: assert result == {2: 5} def test_scanning_backfill_with_missing(self, tmp_path: Path) -> None: + """Test scanning backfill with missing.""" cache_file = tmp_path / "hltb_cache.json" cache_file.write_text( json.dumps({"2": {"hours": 3.0, "polls": 0}}), encoding="utf-8" ) def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + """Test fake fetch.""" data = json.loads(cache_file.read_text(encoding="utf-8")) for aid, _name in games: data[str(aid)] = {"hours": 3.0, "polls": 8} @@ -164,12 +172,14 @@ class TestScanningPollsIntegration: assert result == {2: 8} def test_scanning_backfill_preserves_hours_on_miss(self, tmp_path: Path) -> None: + """Test scanning backfill preserves hours on miss.""" cache_file = tmp_path / "hltb_cache.json" cache_file.write_text( json.dumps({"2": {"hours": 9.0, "polls": 0}}), encoding="utf-8" ) def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + """Test fake fetch.""" data = json.loads(cache_file.read_text(encoding="utf-8")) for aid, _name in games: data[str(aid)] = {"hours": -1, "polls": 0} @@ -195,223 +205,3 @@ class TestScanningPollsIntegration: ) final = json.loads(cache_file.read_text(encoding="utf-8")) assert final["2"]["hours"] == 9.0 - - def test_report_poll_confidence_chosen_zero_polls(self) -> None: - """Covers scanning.py 301-302: 0-poll chosen with history yields warning.""" - echoed: list[str] = [] - chosen = GameInfo( - app_id=1, - name="Chosen", - total_achievements=10, - unlocked_achievements=0, - playtime_minutes=0, - comp_100_count=0, - ) - old = GameInfo( - app_id=2, - name="Old", - total_achievements=10, - unlocked_achievements=10, - playtime_minutes=0, - ) - with ( - patch( - f"{_SCANCONF}._backfill_polls_for_finished", - return_value={1: 0, 2: 5}, - ), - patch( - f"{_SCANCONF}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - ): - scanning._report_poll_confidence( - chosen, [chosen, old], _state([2], current=1) - ) - assert any("no polls recorded" in s for s in echoed) - - def test_do_scan_kept_assignment_missing_game(self) -> None: - """Covers scanning.py 110->116: current_app_id set but game absent.""" - from steam_backlog_enforcer.config import Config - from steam_backlog_enforcer.scanning import do_scan - - other = GameInfo( - app_id=999, - name="Other", - total_achievements=10, - unlocked_achievements=5, - playtime_minutes=0, - ) - - mock_client = MagicMock() - mock_client.build_game_list.return_value = [other] - with ( - patch(f"{_SCAN}.SteamAPIClient", return_value=mock_client), - patch(f"{_SCAN}.fetch_hltb_times_cached", return_value={999: 10.0}), - patch(f"{_SCAN}.save_snapshot"), - patch(f"{_SCAN}.pick_next_game") as mock_pick, - patch(f"{_SCAN}._echo"), - patch(f"{_SCAN}._report_poll_confidence") as mock_report, - ): - config = Config(steam_api_key="k", steam_id="i") - state = State(current_app_id=440) # not in games - do_scan(config, state) - mock_pick.assert_not_called() - mock_report.assert_not_called() - - def test_cmd_done_no_finished_history_chosen_has_polls(self) -> None: - """Covers _cmd_done.py 100->103: no finished history, chosen has >0 polls.""" - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 7}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([], current=1)) - assert any("HLTB confidence: 7" in s for s in echoed) - assert not any("NEW LOW" in s for s in echoed) - assert not any("no polls recorded" in s for s in echoed) - - def test_report_poll_confidence_chosen_equals_min(self) -> None: - """Covers scanning.py 301->304: chosen_polls >= min_polls, no warning.""" - echoed: list[str] = [] - chosen = GameInfo( - app_id=1, - name="Chosen", - total_achievements=10, - unlocked_achievements=0, - playtime_minutes=0, - comp_100_count=5, - ) - old = GameInfo( - app_id=2, - name="Old", - total_achievements=10, - unlocked_achievements=10, - playtime_minutes=0, - ) - with ( - patch( - f"{_SCANCONF}._backfill_polls_for_finished", - return_value={1: 5, 2: 5}, - ), - patch( - f"{_SCANCONF}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - ): - scanning._report_poll_confidence( - chosen, [chosen, old], _state([2], current=1) - ) - assert not any("NEW LOW" in s for s in echoed) - assert not any("no polls recorded" in s for s in echoed) - - def test_refresh_candidate_confidence_noop_when_present(self) -> None: - game = GameInfo( - app_id=1, - name="Known", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=3, - count_comp=15, - ) - with patch(f"{_SCANCONF}.fetch_hltb_confidence_cached") as mock_fetch: - _scanning_confidence._refresh_candidate_confidence(game) - mock_fetch.assert_not_called() - - def test_refresh_candidate_confidence_backfills_zeroes( - self, tmp_path: Path - ) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"1": {"hours": 4.0, "polls": 0, "count_comp": 0}}), - encoding="utf-8", - ) - game = GameInfo( - app_id=1, - name="NeedsRefresh", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=0, - count_comp=0, - ) - - def fake_fetch(_games: list[tuple[int, str]]) -> dict[int, float]: - data = json.loads(cache_file.read_text(encoding="utf-8")) - data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {1: 4.0} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch), - patch(f"{_SCANCONF}._echo"), - ): - _scanning_confidence._refresh_candidate_confidence(game) - - assert game.comp_100_count == 3 - assert game.count_comp == 15 - - def test_filter_hltb_confidence_batches_refreshes(self, tmp_path: Path) -> None: - """Filtering refreshes missing confidence in one batched cache lookup.""" - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps( - { - "1": {"hours": 4.0, "polls": 0, "count_comp": 0}, - "2": {"hours": 5.0, "polls": 0, "count_comp": 0}, - } - ), - encoding="utf-8", - ) - game_a = GameInfo( - app_id=1, - name="A", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=0, - count_comp=0, - ) - game_b = GameInfo( - app_id=2, - name="B", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=0, - count_comp=0, - ) - - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - assert sorted(games) == [(1, "A"), (2, "B")] - data = json.loads(cache_file.read_text(encoding="utf-8")) - data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15} - data["2"] = {"hours": 5.0, "polls": 3, "count_comp": 15} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {1: 4.0, 2: 5.0} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch( - f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch - ) as mock_fetch, - patch(f"{_SCANCONF}._echo"), - ): - kept = _scanning_confidence._filter_hltb_confident_candidates( - [game_a, game_b] - ) - - assert [game.app_id for game in kept] == [1, 2] - mock_fetch.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_report_assigned_confidence.py b/steam_backlog_enforcer/tests/test_polls_tracking_report_assigned_confidence.py new file mode 100644 index 0000000..9819b74 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_polls_tracking_report_assigned_confidence.py @@ -0,0 +1,146 @@ +"""Tests for HLTB poll-count tracking, schema migration, and confidence display.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer import _cmd_done +from steam_backlog_enforcer.config import State + +_TYPES = "steam_backlog_enforcer._hltb_types" +_CMD = "steam_backlog_enforcer._cmd_done" +_SCAN = "steam_backlog_enforcer.scanning" + + +def _state(finished: list[int], current: int | None = None) -> State: + s = State() + s.finished_app_ids = list(finished) + s.current_app_id = current + s.current_game_name = "" + return s + + +class TestReportAssignedConfidence: + """Tests for Report Assigned Confidence.""" + + def test_new_low_warning(self) -> None: + """Test new low warning.""" + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 1, 2: 5, 3: 10}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + {"app_id": 2, "name": "OldShortest"}, + {"app_id": 3, "name": "Other"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([2, 3], current=1)) + assert any("NEW LOW" in s for s in echoed) + assert any("Historical min" in s and "OldShortest" in s for s in echoed) + + def test_zero_polls_warning_with_history(self) -> None: + """Test zero polls warning with history.""" + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 0, 2: 5}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + {"app_id": 2, "name": "Old"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([2], current=1)) + assert any("no polls recorded" in s for s in echoed) + + def test_zero_polls_warning_no_history(self) -> None: + """Test zero polls warning no history.""" + echoed: list[str] = [] + with ( + patch(f"{_CMD}._backfill_polls_for_finished", return_value={1: 0}), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([], current=1)) + assert any("no polls recorded" in s for s in echoed) + assert not any("Historical min" in s for s in echoed) + + def test_healthy_no_warning(self) -> None: + """Test healthy no warning.""" + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 50, 2: 5}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + {"app_id": 2, "name": "Old"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([2], current=1)) + assert not any("NEW LOW" in s for s in echoed) + assert not any("no polls recorded" in s for s in echoed) + assert any("HLTB confidence: 50" in s for s in echoed) + + def test_unknown_finished_uses_appid_label(self) -> None: + """Test unknown finished uses appid label.""" + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 50, 99: 5}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([99], current=1)) + assert any("AppID=99" in s for s in echoed) + + def test_chosen_equals_min_no_warning(self) -> None: + # Edge case: chosen_polls == min_polls (not a new low). + """Test chosen equals min no warning.""" + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 5, 2: 5}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + {"app_id": 2, "name": "Old"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([2], current=1)) + assert not any("NEW LOW" in s for s in echoed) + assert not any("no polls recorded" in s for s in echoed) diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls.py b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls.py new file mode 100644 index 0000000..7b0f820 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls.py @@ -0,0 +1,109 @@ +"""Tests for HLTB poll-count tracking — scanning integration (part 2).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer import _cmd_done, scanning +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.steam_api import GameInfo + +_TYPES = "steam_backlog_enforcer._hltb_types" +_CMD = "steam_backlog_enforcer._cmd_done" +_SCAN = "steam_backlog_enforcer.scanning" +_SCANCONF = "steam_backlog_enforcer._scanning_confidence" + + +def _state(finished: list[int], current: int | None = None) -> State: + s = State() + s.finished_app_ids = list(finished) + s.current_app_id = current + s.current_game_name = "" + return s + + +class TestScanningPollsIntegrationGroup2: + """Tests for Scanning Polls Integration Group2.""" + + def test_report_poll_confidence_chosen_zero_polls(self) -> None: + """Covers scanning.py 301-302: 0-poll chosen with history yields warning.""" + echoed: list[str] = [] + chosen = GameInfo( + app_id=1, + name="Chosen", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=0, + comp_100_count=0, + ) + old = GameInfo( + app_id=2, + name="Old", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=0, + ) + with ( + patch( + f"{_SCANCONF}._backfill_polls_for_finished", + return_value={1: 0, 2: 5}, + ), + patch( + f"{_SCANCONF}._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + ): + scanning._report_poll_confidence( + chosen, [chosen, old], _state([2], current=1) + ) + assert any("no polls recorded" in s for s in echoed) + + def test_do_scan_kept_assignment_missing_game(self) -> None: + """Covers scanning.py 110->116: current_app_id set but game absent.""" + from steam_backlog_enforcer.config import Config + from steam_backlog_enforcer.scanning import do_scan + + other = GameInfo( + app_id=999, + name="Other", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=0, + ) + + mock_client = MagicMock() + mock_client.build_game_list.return_value = [other] + with ( + patch(f"{_SCAN}.SteamAPIClient", return_value=mock_client), + patch(f"{_SCAN}.fetch_hltb_times_cached", return_value={999: 10.0}), + patch(f"{_SCAN}.save_snapshot"), + patch(f"{_SCAN}.pick_next_game") as mock_pick, + patch(f"{_SCAN}._echo"), + patch(f"{_SCAN}._report_poll_confidence") as mock_report, + ): + config = Config(steam_api_key="k", steam_id="i") + state = State(current_app_id=440) # not in games + do_scan(config, state) + mock_pick.assert_not_called() + mock_report.assert_not_called() + + def test_cmd_done_no_finished_history_chosen_has_polls(self) -> None: + """Covers _cmd_done.py 100->103: no finished history, chosen has >0 polls.""" + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 7}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([], current=1)) + assert any("HLTB confidence: 7" in s for s in echoed) + assert not any("NEW LOW" in s for s in echoed) + assert not any("no polls recorded" in s for s in echoed) diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py new file mode 100644 index 0000000..673b183 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py @@ -0,0 +1,172 @@ +"""Tests for HLTB poll-count tracking — scanning integration (part 2).""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from steam_backlog_enforcer import _scanning_confidence, scanning +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.steam_api import GameInfo + +if TYPE_CHECKING: + from pathlib import Path + +_TYPES = "steam_backlog_enforcer._hltb_types" +_CMD = "steam_backlog_enforcer._cmd_done" +_SCAN = "steam_backlog_enforcer.scanning" +_SCANCONF = "steam_backlog_enforcer._scanning_confidence" + + +def _state(finished: list[int], current: int | None = None) -> State: + s = State() + s.finished_app_ids = list(finished) + s.current_app_id = current + s.current_game_name = "" + return s + + +class TestScanningPollsIntegrationGroup2Group2: + """Tests for Scanning Polls Integration Group2.""" + + def test_report_poll_confidence_chosen_equals_min(self) -> None: + """Covers scanning.py 301->304: chosen_polls >= min_polls, no warning.""" + echoed: list[str] = [] + chosen = GameInfo( + app_id=1, + name="Chosen", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=0, + comp_100_count=5, + ) + old = GameInfo( + app_id=2, + name="Old", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=0, + ) + with ( + patch( + f"{_SCANCONF}._backfill_polls_for_finished", + return_value={1: 5, 2: 5}, + ), + patch( + f"{_SCANCONF}._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + ): + scanning._report_poll_confidence( + chosen, [chosen, old], _state([2], current=1) + ) + assert not any("NEW LOW" in s for s in echoed) + assert not any("no polls recorded" in s for s in echoed) + + def test_refresh_candidate_confidence_noop_when_present(self) -> None: + """Test refresh candidate confidence noop when present.""" + game = GameInfo( + app_id=1, + name="Known", + total_achievements=10, + unlocked_achievements=1, + playtime_minutes=0, + comp_100_count=3, + count_comp=15, + ) + with patch(f"{_SCANCONF}.fetch_hltb_confidence_cached") as mock_fetch: + _scanning_confidence._refresh_candidate_confidence(game) + mock_fetch.assert_not_called() + + def test_refresh_candidate_confidence_backfills_zeroes( + self, tmp_path: Path + ) -> None: + """Test refresh candidate confidence backfills zeroes.""" + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"1": {"hours": 4.0, "polls": 0, "count_comp": 0}}), + encoding="utf-8", + ) + game = GameInfo( + app_id=1, + name="NeedsRefresh", + total_achievements=10, + unlocked_achievements=1, + playtime_minutes=0, + comp_100_count=0, + count_comp=0, + ) + + def fake_fetch(_games: list[tuple[int, str]]) -> dict[int, float]: + """Test fake fetch.""" + data = json.loads(cache_file.read_text(encoding="utf-8")) + data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {1: 4.0} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch(f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + patch(f"{_SCANCONF}._echo"), + ): + _scanning_confidence._refresh_candidate_confidence(game) + + assert game.comp_100_count == 3 + assert game.count_comp == 15 + + def test_filter_hltb_confidence_batches_refreshes(self, tmp_path: Path) -> None: + """Filtering refreshes missing confidence in one batched cache lookup.""" + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps( + { + "1": {"hours": 4.0, "polls": 0, "count_comp": 0}, + "2": {"hours": 5.0, "polls": 0, "count_comp": 0}, + } + ), + encoding="utf-8", + ) + game_a = GameInfo( + app_id=1, + name="A", + total_achievements=10, + unlocked_achievements=1, + playtime_minutes=0, + comp_100_count=0, + count_comp=0, + ) + game_b = GameInfo( + app_id=2, + name="B", + total_achievements=10, + unlocked_achievements=1, + playtime_minutes=0, + comp_100_count=0, + count_comp=0, + ) + + def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + """Test fake fetch.""" + assert sorted(games) == [(1, "A"), (2, "B")] + data = json.loads(cache_file.read_text(encoding="utf-8")) + data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15} + data["2"] = {"hours": 5.0, "polls": 3, "count_comp": 15} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {1: 4.0, 2: 5.0} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch( + f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch + ) as mock_fetch, + patch(f"{_SCANCONF}._echo"), + ): + kept = _scanning_confidence._filter_hltb_confident_candidates( + [game_a, game_b] + ) + + assert [game.app_id for game in kept] == [1, 2] + mock_fetch.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_protondb.py b/steam_backlog_enforcer/tests/test_protondb.py index bf3730a..732b022 100644 --- a/steam_backlog_enforcer/tests/test_protondb.py +++ b/steam_backlog_enforcer/tests/test_protondb.py @@ -2,23 +2,16 @@ from __future__ import annotations -import asyncio import json from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, MagicMock, patch - -import aiohttp +from unittest.mock import patch from steam_backlog_enforcer.protondb import ( - HTTP_NOT_FOUND, ProtonDBRating, - _fetch_batch, - _fetch_one, _load_cache, _rating_from_cache, _rating_to_dict, _save_cache, - fetch_protondb_ratings, ) if TYPE_CHECKING: @@ -29,82 +22,102 @@ class TestProtonDBRating: """Tests for ProtonDBRating.""" def test_playable_native(self) -> None: + """Test playable native.""" r = ProtonDBRating(app_id=1, tier="native") assert r.is_playable is True def test_playable_platinum(self) -> None: + """Test playable platinum.""" r = ProtonDBRating(app_id=1, tier="platinum") assert r.is_playable is True def test_playable_gold(self) -> None: + """Test playable gold.""" r = ProtonDBRating(app_id=1, tier="gold") assert r.is_playable is True def test_not_playable_silver(self) -> None: + """Test not playable silver.""" r = ProtonDBRating(app_id=1, tier="silver") assert r.is_playable is False def test_not_playable_bronze(self) -> None: + """Test not playable bronze.""" r = ProtonDBRating(app_id=1, tier="bronze") assert r.is_playable is False def test_not_playable_borked(self) -> None: + """Test not playable borked.""" r = ProtonDBRating(app_id=1, tier="borked") assert r.is_playable is False def test_playable_no_data(self) -> None: + """Test playable no data.""" r = ProtonDBRating(app_id=1, tier="") assert r.is_playable is True def test_playable_pending(self) -> None: + """Test playable pending.""" r = ProtonDBRating(app_id=1, tier="pending") assert r.is_playable is True def test_gold_trending_silver(self) -> None: + """Test gold trending silver.""" r = ProtonDBRating(app_id=1, tier="gold", trending_tier="silver") assert r.is_playable is True def test_gold_trending_gold(self) -> None: + """Test gold trending gold.""" r = ProtonDBRating(app_id=1, tier="gold", trending_tier="gold") assert r.is_playable is True def test_silver_trending_gold(self) -> None: + """Test silver trending gold.""" r = ProtonDBRating(app_id=1, tier="silver", trending_tier="gold") assert r.is_playable is True def test_gold_no_trending(self) -> None: + """Test gold no trending.""" r = ProtonDBRating(app_id=1, tier="gold", trending_tier="") assert r.is_playable is True def test_gold_trending_platinum(self) -> None: + """Test gold trending platinum.""" r = ProtonDBRating(app_id=1, tier="gold", trending_tier="platinum") assert r.is_playable is True def test_gold_trending_unknown(self) -> None: + """Test gold trending unknown.""" r = ProtonDBRating(app_id=1, tier="gold", trending_tier="unknown") assert r.is_playable is False def test_gold_trending_bronze(self) -> None: + """Test gold trending bronze.""" r = ProtonDBRating(app_id=1, tier="gold", trending_tier="bronze") assert r.is_playable is False def test_unknown_tier(self) -> None: + """Test unknown tier.""" r = ProtonDBRating(app_id=1, tier="unknown_tier") assert r.is_playable is False def test_unplayable_reason_no_trending_tier(self) -> None: + """Test unplayable reason no trending tier.""" r = ProtonDBRating(app_id=1, tier="borked") assert "tier<" in r.unplayable_reason def test_unplayable_reason_for_silver_silver(self) -> None: + """Test unplayable reason for silver silver.""" r = ProtonDBRating(app_id=1, tier="silver", trending_tier="silver") assert "no gold tier" in r.unplayable_reason def test_unplayable_reason_for_gold_bronze(self) -> None: + """Test unplayable reason for gold bronze.""" r = ProtonDBRating(app_id=1, tier="gold", trending_tier="bronze") assert "below silver" in r.unplayable_reason def test_unplayable_reason_empty_when_playable(self) -> None: + """Test unplayable reason empty when playable.""" r = ProtonDBRating(app_id=1, tier="gold") assert r.unplayable_reason == "" @@ -113,6 +126,7 @@ class TestProtonDBCache: """Tests for cache I/O.""" def test_load_cache_exists(self, tmp_path: Path) -> None: + """Test load cache exists.""" cache_file = tmp_path / "protondb_cache.json" cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8") with patch( @@ -123,6 +137,7 @@ class TestProtonDBCache: assert result == {"440": {"tier": "gold"}} def test_load_cache_missing(self, tmp_path: Path) -> None: + """Test load cache missing.""" cache_file = tmp_path / "nonexistent.json" with patch( "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", @@ -131,6 +146,7 @@ class TestProtonDBCache: assert _load_cache() == {} def test_save_cache(self, tmp_path: Path) -> None: + """Test save cache.""" cache_file = tmp_path / "protondb_cache.json" config_dir = tmp_path with ( @@ -148,6 +164,7 @@ class TestRatingConversion: """Tests for rating serialization.""" def test_to_dict(self) -> None: + """Test to dict.""" r = ProtonDBRating( app_id=1, tier="gold", @@ -161,6 +178,7 @@ class TestRatingConversion: assert d["total_reports"] == 100 def test_from_cache(self) -> None: + """Test from cache.""" data: dict[str, Any] = { "tier": "silver", "trending_tier": "bronze", @@ -172,120 +190,7 @@ class TestRatingConversion: assert r.trending_tier == "bronze" def test_from_cache_defaults(self) -> None: + """Test from cache defaults.""" r = _rating_from_cache(440, {}) assert r.tier == "" assert r.total_reports == 0 - - -class TestFetchOne: - """Tests for _fetch_one.""" - - def test_success(self) -> None: - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.raise_for_status = MagicMock() - mock_resp.json = AsyncMock( - return_value={"tier": "gold", "trendingTier": "platinum"} - ) - mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) - mock_resp.__aexit__ = AsyncMock(return_value=False) - - mock_session = MagicMock() - mock_session.get = MagicMock(return_value=mock_resp) - - sem = asyncio.Semaphore(1) - result = asyncio.run(_fetch_one(mock_session, sem, 440)) - assert result.tier == "gold" - - def test_not_found(self) -> None: - mock_resp = AsyncMock() - mock_resp.status = HTTP_NOT_FOUND - mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) - mock_resp.__aexit__ = AsyncMock(return_value=False) - - mock_session = MagicMock() - mock_session.get = MagicMock(return_value=mock_resp) - - sem = asyncio.Semaphore(1) - result = asyncio.run(_fetch_one(mock_session, sem, 440)) - assert result.tier == "" - - def test_client_error(self) -> None: - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.raise_for_status = MagicMock(side_effect=aiohttp.ClientError) - mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) - mock_resp.__aexit__ = AsyncMock(return_value=False) - - mock_session = MagicMock() - mock_session.get = MagicMock(return_value=mock_resp) - - sem = asyncio.Semaphore(1) - result = asyncio.run(_fetch_one(mock_session, sem, 440)) - assert result is None - - -class TestFetchBatch: - """Tests for _fetch_batch.""" - - def test_returns_ratings(self) -> None: - rating = ProtonDBRating(app_id=440, tier="gold") - with patch( - "steam_backlog_enforcer.protondb._fetch_one", - new_callable=AsyncMock, - return_value=rating, - ): - result = asyncio.run(_fetch_batch([440])) - assert len(result) == 1 - assert result[0].tier == "gold" - - def test_filters_none_results(self) -> None: - """Network failures (None) are filtered out of the batch result.""" - rating = ProtonDBRating(app_id=440, tier="gold") - - async def mock_fetch_one( - _session: aiohttp.ClientSession, - _sem: asyncio.Semaphore, - app_id: int, - ) -> ProtonDBRating | None: - return rating if app_id == 440 else None - - with patch( - "steam_backlog_enforcer.protondb._fetch_one", - side_effect=mock_fetch_one, - ): - result = asyncio.run(_fetch_batch([440, 999])) - assert len(result) == 1 - assert result[0].app_id == 440 - - -class TestFetchProtondbRatings: - """Tests for fetch_protondb_ratings.""" - - def test_all_cached(self, tmp_path: Path) -> None: - cache_file = tmp_path / "protondb_cache.json" - cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8") - with patch( - "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ): - result = fetch_protondb_ratings([440]) - assert 440 in result - assert result[440].tier == "gold" - - def test_fetch_uncached(self, tmp_path: Path) -> None: - cache_file = tmp_path / "protondb_cache.json" - config_dir = tmp_path - with ( - patch( - "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ), - patch("steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir), - patch( - "steam_backlog_enforcer.protondb._fetch_batch", - return_value=[ProtonDBRating(app_id=440, tier="platinum")], - ), - ): - result = fetch_protondb_ratings([440]) - assert result[440].tier == "platinum" diff --git a/steam_backlog_enforcer/tests/test_protondb_fetch_batch.py b/steam_backlog_enforcer/tests/test_protondb_fetch_batch.py new file mode 100644 index 0000000..5e8b517 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_protondb_fetch_batch.py @@ -0,0 +1,89 @@ +"""Tests for protondb module.""" + +from __future__ import annotations + +import asyncio +import json +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +from steam_backlog_enforcer.protondb import ( + ProtonDBRating, + _fetch_batch, + fetch_protondb_ratings, +) + +if TYPE_CHECKING: + from pathlib import Path + + import aiohttp + + +class TestFetchBatch: + """Tests for _fetch_batch.""" + + def test_returns_ratings(self) -> None: + """Test returns ratings.""" + rating = ProtonDBRating(app_id=440, tier="gold") + with patch( + "steam_backlog_enforcer.protondb._fetch_one", + new_callable=AsyncMock, + return_value=rating, + ): + result = asyncio.run(_fetch_batch([440])) + assert len(result) == 1 + assert result[0].tier == "gold" + + def test_filters_none_results(self) -> None: + """Network failures (None) are filtered out of the batch result.""" + rating = ProtonDBRating(app_id=440, tier="gold") + + async def mock_fetch_one( + _session: aiohttp.ClientSession, + _sem: asyncio.Semaphore, + app_id: int, + ) -> ProtonDBRating | None: + """Test mock fetch one.""" + return rating if app_id == 440 else None + + with patch( + "steam_backlog_enforcer.protondb._fetch_one", + side_effect=mock_fetch_one, + ): + result = asyncio.run(_fetch_batch([440, 999])) + assert len(result) == 1 + assert result[0].app_id == 440 + + +class TestFetchProtondbRatings: + """Tests for fetch_protondb_ratings.""" + + def test_all_cached(self, tmp_path: Path) -> None: + """Test all cached.""" + cache_file = tmp_path / "protondb_cache.json" + cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8") + with patch( + "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", + cache_file, + ): + result = fetch_protondb_ratings([440]) + assert 440 in result + assert result[440].tier == "gold" + + def test_fetch_uncached(self, tmp_path: Path) -> None: + """Test fetch uncached.""" + cache_file = tmp_path / "protondb_cache.json" + config_dir = tmp_path + with ( + patch( + "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", + cache_file, + ), + patch("steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir), + patch( + "steam_backlog_enforcer.protondb._fetch_batch", + return_value=[ProtonDBRating(app_id=440, tier="platinum")], + ), + ): + result = fetch_protondb_ratings([440]) + assert result[440].tier == "platinum" diff --git a/steam_backlog_enforcer/tests/test_protondb_fetch_one.py b/steam_backlog_enforcer/tests/test_protondb_fetch_one.py new file mode 100644 index 0000000..a8db27e --- /dev/null +++ b/steam_backlog_enforcer/tests/test_protondb_fetch_one.py @@ -0,0 +1,64 @@ +"""Tests for protondb module.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import aiohttp + +from steam_backlog_enforcer.protondb import ( + HTTP_NOT_FOUND, + _fetch_one, +) + + +class TestFetchOne: + """Tests for _fetch_one.""" + + def test_success(self) -> None: + """Test success.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json = AsyncMock( + return_value={"tier": "gold", "trendingTier": "platinum"} + ) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + + sem = asyncio.Semaphore(1) + result = asyncio.run(_fetch_one(mock_session, sem, 440)) + assert result.tier == "gold" + + def test_not_found(self) -> None: + """Test not found.""" + mock_resp = AsyncMock() + mock_resp.status = HTTP_NOT_FOUND + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + + sem = asyncio.Semaphore(1) + result = asyncio.run(_fetch_one(mock_session, sem, 440)) + assert result.tier == "" + + def test_client_error(self) -> None: + """Test client error.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.raise_for_status = MagicMock(side_effect=aiohttp.ClientError) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + + sem = asyncio.Semaphore(1) + result = asyncio.run(_fetch_one(mock_session, sem, 440)) + assert result is None diff --git a/steam_backlog_enforcer/tests/test_scanning.py b/steam_backlog_enforcer/tests/test_scanning.py index f930760..1b253d1 100644 --- a/steam_backlog_enforcer/tests/test_scanning.py +++ b/steam_backlog_enforcer/tests/test_scanning.py @@ -10,7 +10,6 @@ from steam_backlog_enforcer.protondb import ProtonDBRating from steam_backlog_enforcer.scanning import ( _pick_playable_candidate, do_scan, - pick_next_game, ) from steam_backlog_enforcer.steam_api import GameInfo @@ -41,6 +40,7 @@ class TestDoScan: """Tests for do_scan.""" def test_scans_and_picks(self) -> None: + """Test scans and picks.""" game = _game(app_id=440, name="TF2", total=10, unlocked=5) mock_client = MagicMock() @@ -48,6 +48,7 @@ class TestDoScan: progress_callback: Callable[..., object] | None = None, ) -> list[GameInfo]: # Trigger progress callback to cover those lines. + """Test build game list.""" if progress_callback: progress_callback(50, 100) progress_callback(100, 100) @@ -83,12 +84,14 @@ class TestDoScan: mock_pick.assert_called_once() def test_scan_all_complete(self) -> None: + """Test scan all complete.""" game = _game(app_id=440, name="TF2", total=10, unlocked=10) mock_client = MagicMock() def build_game_list( progress_callback: Callable[..., object] | None = None, ) -> list[GameInfo]: + """Test build game list.""" if progress_callback: # current=1, total=2 → not %50 and not ==total → covers False branch progress_callback(1, 2) @@ -115,6 +118,7 @@ class TestDoScan: mock_pick.assert_called_once() def test_scan_already_assigned(self) -> None: + """Test scan already assigned.""" game = _game(app_id=440, total=10, unlocked=5) mock_client = MagicMock() mock_client.build_game_list.return_value = [game] @@ -146,6 +150,7 @@ class TestPickPlayableCandidate: """Tests for _pick_playable_candidate.""" def test_finds_playable(self) -> None: + """Test finds playable.""" game = _game(app_id=440, name="TF2") with ( patch( @@ -161,6 +166,7 @@ class TestPickPlayableCandidate: assert result.app_id == 440 def test_skips_bad_rating(self) -> None: + """Test skips bad rating.""" bad = _game(app_id=1, name="Bad") good = _game(app_id=2, name="Good") with ( @@ -178,6 +184,7 @@ class TestPickPlayableCandidate: assert result.app_id == 2 def test_all_unplayable(self) -> None: + """Test all unplayable.""" game = _game(app_id=1, name="Bad") with ( patch( @@ -191,6 +198,7 @@ class TestPickPlayableCandidate: assert _pick_playable_candidate([game]) is None def test_empty_list(self) -> None: + """Test empty list.""" assert _pick_playable_candidate([]) is None def test_first_in_batch_playable(self) -> None: @@ -207,238 +215,3 @@ class TestPickPlayableCandidate: ): result = _pick_playable_candidate([game]) assert result is not None - - -class TestPickNextGame: - """Tests for pick_next_game.""" - - def test_picks_shortest(self) -> None: - g1 = _game(app_id=1, name="Long", hours=100.0) - g2 = _game(app_id=2, name="Short", hours=10.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch("steam_backlog_enforcer._scanning_confidence._echo"), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1, g2], state, config) - assert state.current_app_id == 2 - - def test_no_candidates(self) -> None: - g1 = _game(app_id=1, total=5, unlocked=5) - config = Config(steam_api_key="k", steam_id="i") - state = State() - with patch("steam_backlog_enforcer.scanning._echo"): - pick_next_game([g1], state, config) - assert state.current_app_id is None - - def test_skips_finished(self) -> None: - g1 = _game(app_id=1, name="G1", hours=10.0) - g2 = _game(app_id=2, name="G2", hours=20.0) - config = Config(steam_api_key="k", steam_id="i") - state = State(finished_app_ids=[1]) - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1, g2], state, config) - assert state.current_app_id == 2 - - def test_no_playable(self) -> None: - g1 = _game(app_id=1, name="G1") - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - return_value=None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - ): - pick_next_game([g1], state, config) - assert state.current_app_id is None - - def test_uninstalls_others(self) -> None: - g1 = _game(app_id=1, name="G1", hours=10.0) - config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=True) - state = State() - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch("steam_backlog_enforcer._scanning_confidence._echo"), - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=2, - ), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1], state, config) - assert state.current_app_id == 1 - - def test_auto_installs(self) -> None: - g1 = _game(app_id=1, name="G1", hours=10.0) - config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=False) - state = State() - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch("steam_backlog_enforcer._scanning_confidence._echo"), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=False, - ), - patch("steam_backlog_enforcer.scanning.install_game") as mock_install, - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1], state, config) - mock_install.assert_called_once() - - def test_unknown_hours(self) -> None: - g1 = _game(app_id=1, name="G1", hours=-1) - g2 = _game(app_id=2, name="G2", hours=10.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1, g2], state, config) - assert state.current_app_id == 2 - - def test_picks_game_no_hours(self) -> None: - """Chosen game has no HLTB hours — covers no-hours output branch.""" - g1 = _game(app_id=1, name="G1", hours=-1) - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1], state, config) - assert state.current_app_id == 1 - - def test_skips_low_confidence_and_picks_next(self) -> None: - low = _game(app_id=1, name="LowConfidence", hours=1.0) - low.comp_100_count = 1 - low.count_comp = 5 - valid = _game(app_id=2, name="ValidConfidence", hours=2.0) - valid.comp_100_count = 3 - valid.count_comp = 15 - echoed: list[str] = [] - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer._scanning_confidence._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([low, valid], state, config) - assert state.current_app_id == 2 - assert any("Skipping LowConfidence" in line for line in echoed) - assert any("comp_100 polls 1 < 3" in line for line in echoed) - - def test_all_candidates_filtered_by_confidence(self) -> None: - low_a = _game(app_id=1, name="LowA", hours=1.0) - low_a.comp_100_count = 2 - low_a.count_comp = 15 - low_b = _game(app_id=2, name="LowB", hours=2.0) - low_b.comp_100_count = 3 - low_b.count_comp = 14 - echoed: list[str] = [] - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer._scanning_confidence._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - return_value=None, - ) as mock_pick, - ): - pick_next_game([low_a, low_b], state, config) - assert state.current_app_id is None - mock_pick.assert_not_called() - assert any("No assignable games found" in line for line in echoed) diff --git a/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py b/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py new file mode 100644 index 0000000..80321a5 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py @@ -0,0 +1,198 @@ +"""Tests for scanning module (part 3): TestPickNextGame continued.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.scanning import pick_next_game +from steam_backlog_enforcer.steam_api import GameInfo + + +def _game( + app_id: int = 1, + name: str = "G", + total: int = 10, + unlocked: int = 0, + hours: float = -1, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=3, + count_comp=15, + ) + + +class TestPickNextGameGroup2: + """Tests for pick_next_game (continued from test_scanning.py).""" + + def test_cached_confidence_overlay_avoids_refetch_for_zero_snapshot_fields( + self, + ) -> None: + """Use cached confidence before deciding whether refresh is needed.""" + low = _game(app_id=1, name="Low", hours=1.0) + low.comp_100_count = 0 + low.count_comp = 0 + fallback = _game(app_id=2, name="Fallback", hours=2.0) + fallback.comp_100_count = 3 + fallback.count_comp = 20 + + config = Config(steam_api_key="k", steam_id="i") + state = State() + + with ( + patch( + "steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache", + return_value={1: 1, 2: 3}, + ), + patch( + "steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache", + return_value={1: 8, 2: 20}, + ), + patch( + "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch" + ) as mock_refresh_batch, + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + patch("builtins.input", return_value="1"), + ): + pick_next_game([low, fallback], state, config) + + assert state.current_app_id == 2 + mock_refresh_batch.assert_not_called() + + def test_stops_collecting_after_n_qualified(self) -> None: + """Collection stops once _PICK_LIST_SIZE candidates are qualified.""" + # Create 11 games that all pass filters; only the first 10 should be + # presented and the 11th should never trigger a ProtonDB call. + games = [_game(app_id=i, name=f"G{i}", hours=float(i)) for i in range(1, 12)] + protondb_call_count = 0 + + def playable_side_effect(c: list[GameInfo]) -> GameInfo | None: + """Test playable side effect.""" + nonlocal protondb_call_count + protondb_call_count += 1 + return c[0] if c else None + + config = Config(steam_api_key="k", steam_id="i") + state = State() + + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=playable_side_effect, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + patch("builtins.input", return_value="1"), + ): + pick_next_game(games, state, config) + + assert state.current_app_id == 1 + assert protondb_call_count == 10 + + def test_user_picks_second_candidate(self) -> None: + """User can select a game other than the shortest one.""" + g1 = _game(app_id=1, name="Short", hours=5.0) + g2 = _game(app_id=2, name="Medium", hours=15.0) + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + patch("builtins.input", return_value="2"), + ): + pick_next_game([g1, g2], state, config) + assert state.current_app_id == 2 + + def test_invalid_input_then_valid(self) -> None: + """Non-numeric input prints error and loops until valid input.""" + g1 = _game(app_id=1, name="G1", hours=5.0) + config = Config(steam_api_key="k", steam_id="i") + state = State() + echoed: list[str] = [] + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch( + "steam_backlog_enforcer.scanning._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + patch("builtins.input", side_effect=["abc", "1"]), + ): + pick_next_game([g1], state, config) + assert state.current_app_id == 1 + assert any("Invalid input" in line for line in echoed) + + def test_out_of_range_then_valid(self) -> None: + """Out-of-range number prints error and loops until valid input.""" + g1 = _game(app_id=1, name="G1", hours=5.0) + config = Config(steam_api_key="k", steam_id="i") + state = State() + echoed: list[str] = [] + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch( + "steam_backlog_enforcer.scanning._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + patch("builtins.input", side_effect=["99", "1"]), + ): + pick_next_game([g1], state, config) + assert state.current_app_id == 1 + assert any("Out of range" in line for line in echoed) diff --git a/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py new file mode 100644 index 0000000..729b743 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py @@ -0,0 +1,133 @@ +"""Scanning tests (part 4): collect_top_candidates, do_check, confidence.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer._scanning_confidence import ( + _filter_hltb_confident_candidates, + _force_refresh_candidate_confidence, + _refresh_candidate_confidence_batch, +) +from steam_backlog_enforcer.scanning import ( + _pick_next_shortest_candidate, +) +from steam_backlog_enforcer.steam_api import GameInfo + + +def _game( + app_id: int = 1, + name: str = "G", + total: int = 10, + unlocked: int = 0, + hours: float = -1, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=3, + count_comp=15, + ) + + +class TestConfidenceHelpers: + """Coverage-focused tests for scanning confidence helper branches.""" + + def test_force_refresh_candidate_confidence_delegates(self) -> None: + """Test force refresh candidate confidence delegates.""" + game = _game(app_id=10, name="A") + with patch( + "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch", + ) as mock_batch: + _force_refresh_candidate_confidence(game) + mock_batch.assert_called_once_with([game], force=True) + + def test_refresh_candidate_confidence_batch_no_missing_skips_fetch(self) -> None: + """Test refresh candidate confidence batch no missing skips fetch.""" + game = _game(app_id=20, name="B", hours=12.0) + game.comp_100_count = 3 + game.count_comp = 15 + with patch( + "steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached", + ) as mock_fetch: + _refresh_candidate_confidence_batch([game], force=False) + mock_fetch.assert_not_called() + + def test_refresh_candidate_confidence_batch_preserves_existing_hours(self) -> None: + """Test refresh candidate confidence batch preserves existing hours.""" + game = _game(app_id=30, name="C", hours=9.5) + game.comp_100_count = 0 + game.count_comp = 0 + with ( + patch( + "steam_backlog_enforcer._scanning_confidence.load_hltb_cache", + side_effect=[{30: 9.5}, {30: -1.0}], + ), + patch( + "steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache", + return_value={30: 0}, + ), + patch( + "steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache", + return_value={30: 0}, + ), + patch( + "steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached", + return_value={30: -1.0}, + ), + patch( + "steam_backlog_enforcer._scanning_confidence.save_hltb_cache", + ) as mock_save, + ): + _refresh_candidate_confidence_batch([game], force=True) + + assert game.completionist_hours == 9.5 + saved_cache = mock_save.call_args.args[0] + assert saved_cache[30] == 9.5 + + def test_filter_hltb_confident_candidates_skips_low_confidence(self) -> None: + """Test filter hltb confident candidates skips low confidence.""" + low = _game(app_id=40, name="Low", hours=2.0) + low.comp_100_count = 1 + low.count_comp = 2 + with ( + patch( + "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch", + ), + patch("steam_backlog_enforcer._scanning_confidence._echo") as mock_echo, + ): + result = _filter_hltb_confident_candidates([low]) + assert result == [] + assert mock_echo.called + + def test_pick_next_shortest_candidate_logs_skipped_unplayable_batches(self) -> None: + """Test pick next shortest candidate logs skipped unplayable batches.""" + bad = _game(app_id=50, name="Bad", hours=1.0) + good = _game(app_id=51, name="Good", hours=2.0) + bad.comp_100_count = 3 + bad.count_comp = 15 + good.comp_100_count = 3 + good.count_comp = 15 + + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=[None, good], + ), + patch("steam_backlog_enforcer.scanning._echo") as mock_echo, + ): + picked, skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( + [bad, good], + ) + + assert picked is good + assert skipped_low_conf == 0 + assert skipped_linux == 1 + assert any( + "Skipped 1 game(s) with poor Linux compatibility" in str(call) + for call in mock_echo.call_args_list + ) diff --git a/steam_backlog_enforcer/tests/test_scanning_confidence_helpers_pick_next_shortest_candidate_no_echo_whe.py b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers_pick_next_shortest_candidate_no_echo_whe.py new file mode 100644 index 0000000..d66fa57 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers_pick_next_shortest_candidate_no_echo_whe.py @@ -0,0 +1,157 @@ +"""Scanning tests (part 4): collect_top_candidates, do_check, confidence.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.scanning import ( + _pick_next_shortest_candidate, + do_check, +) +from steam_backlog_enforcer.steam_api import GameInfo + + +def _game( + app_id: int = 1, + name: str = "G", + total: int = 10, + unlocked: int = 0, + hours: float = -1, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=3, + count_comp=15, + ) + + +class TestConfidenceHelpersGroup2: + """Coverage-focused tests for scanning confidence helper branches.""" + + def test_pick_next_shortest_candidate_no_echo_when_linux_skipped_zero( + self, + ) -> None: + """Covers 419->423: no echo printed when linux_skipped == 0.""" + good = _game(app_id=51, name="Good", hours=2.0) + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + return_value=good, + ), + patch("steam_backlog_enforcer.scanning._echo") as mock_echo, + ): + picked, _skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( + [good], + ) + assert picked is good + assert skipped_linux == 0 + mock_echo.assert_not_called() + + def test_pick_next_shortest_candidate_skips_low_confidence(self) -> None: + """Covers lines 413-414: confidence_skipped += 1; continue.""" + low_conf = _game(app_id=10, name="Low", hours=1.0) + low_conf.comp_100_count = 0 + low_conf.count_comp = 0 + with ( + patch( + "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence" + ), + patch("steam_backlog_enforcer.scanning._echo"), + ): + picked, skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( + [low_conf], + ) + assert picked is None + assert skipped_low_conf == 1 + assert skipped_linux == 0 + + def test_pick_next_shortest_candidate_all_protondb_fail(self) -> None: + """Covers lines 426-428: linux_skipped > 0 after loop, return None.""" + g1 = _game(app_id=10, name="Borked", hours=1.0) + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + return_value=None, + ), + patch("steam_backlog_enforcer.scanning._echo") as mock_echo, + ): + picked, _skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( + [g1], + ) + assert picked is None + assert skipped_linux == 1 + assert any( + "Skipped 1 game(s) with poor Linux compatibility" in str(call) + for call in mock_echo.call_args_list + ) + + game = _game(app_id=440, name="TF2", total=5, unlocked=5) + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = game + snap = [game.to_snapshot()] + with ( + patch( + "steam_backlog_enforcer.scanning.SteamAPIClient", + return_value=mock_client, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch( + "steam_backlog_enforcer.scanning.send_notification", + ), + patch( + "steam_backlog_enforcer.scanning.load_snapshot", + return_value=snap, + ), + patch( + "steam_backlog_enforcer.scanning.pick_next_game", + ), + patch("steam_backlog_enforcer.scanning.detect_tampering"), + ): + state = State(current_app_id=440, current_game_name="TF2") + do_check(Config(steam_api_key="k", steam_id="i"), state) + assert 440 in state.finished_app_ids + + def test_complete_no_snapshot(self) -> None: + """Test complete no snapshot.""" + game = _game(app_id=440, name="TF2", total=5, unlocked=5) + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = game + with ( + patch( + "steam_backlog_enforcer.scanning.SteamAPIClient", + return_value=mock_client, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch( + "steam_backlog_enforcer.scanning.send_notification", + ), + patch( + "steam_backlog_enforcer.scanning.load_snapshot", + return_value=None, + ), + patch("steam_backlog_enforcer.scanning.detect_tampering"), + ): + state = State(current_app_id=440, current_game_name="TF2") + do_check(Config(steam_api_key="k", steam_id="i"), state) + + def test_not_complete(self) -> None: + """Test not complete.""" + game = _game(app_id=440, name="TF2", total=10, unlocked=5) + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = game + with ( + patch( + "steam_backlog_enforcer.scanning.SteamAPIClient", + return_value=mock_client, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch("steam_backlog_enforcer.scanning.detect_tampering"), + ): + state = State(current_app_id=440, current_game_name="TF2") + do_check(Config(steam_api_key="k", steam_id="i"), state) diff --git a/steam_backlog_enforcer/tests/test_scanning_part3.py b/steam_backlog_enforcer/tests/test_scanning_part3.py index 29ef412..030a786 100644 --- a/steam_backlog_enforcer/tests/test_scanning_part3.py +++ b/steam_backlog_enforcer/tests/test_scanning_part3.py @@ -2,7 +2,6 @@ from __future__ import annotations -import contextlib from unittest.mock import patch from steam_backlog_enforcer.config import Config, State @@ -44,6 +43,7 @@ class TestPickNextGame: echoed: list[str] = [] def refresh_side_effect(game: GameInfo) -> None: + """Test refresh side effect.""" if game.app_id == 1: game.comp_100_count = 899 game.count_comp = 14055 @@ -114,299 +114,3 @@ class TestPickNextGame: assert state.current_app_id == 2 mock_refresh_batch.assert_not_called() - - def test_cached_confidence_overlay_avoids_refetch_for_zero_snapshot_fields( - self, - ) -> None: - """Use cached confidence before deciding whether refresh is needed.""" - low = _game(app_id=1, name="Low", hours=1.0) - low.comp_100_count = 0 - low.count_comp = 0 - fallback = _game(app_id=2, name="Fallback", hours=2.0) - fallback.comp_100_count = 3 - fallback.count_comp = 20 - - config = Config(steam_api_key="k", steam_id="i") - state = State() - - with ( - patch( - "steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache", - return_value={1: 1, 2: 3}, - ), - patch( - "steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache", - return_value={1: 8, 2: 20}, - ), - patch( - "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch" - ) as mock_refresh_batch, - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([low, fallback], state, config) - - assert state.current_app_id == 2 - mock_refresh_batch.assert_not_called() - - def test_stops_collecting_after_n_qualified(self) -> None: - """Collection stops once _PICK_LIST_SIZE candidates are qualified.""" - # Create 11 games that all pass filters; only the first 10 should be - # presented and the 11th should never trigger a ProtonDB call. - games = [_game(app_id=i, name=f"G{i}", hours=float(i)) for i in range(1, 12)] - protondb_call_count = 0 - - def playable_side_effect(c: list[GameInfo]) -> GameInfo | None: - nonlocal protondb_call_count - protondb_call_count += 1 - return c[0] if c else None - - config = Config(steam_api_key="k", steam_id="i") - state = State() - - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=playable_side_effect, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game(games, state, config) - - assert state.current_app_id == 1 - assert protondb_call_count == 10 - - def test_user_picks_second_candidate(self) -> None: - """User can select a game other than the shortest one.""" - g1 = _game(app_id=1, name="Short", hours=5.0) - g2 = _game(app_id=2, name="Medium", hours=15.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="2"), - ): - pick_next_game([g1, g2], state, config) - assert state.current_app_id == 2 - - def test_invalid_input_then_valid(self) -> None: - """Non-numeric input prints error and loops until valid input.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", side_effect=["abc", "1"]), - ): - pick_next_game([g1], state, config) - assert state.current_app_id == 1 - assert any("Invalid input" in line for line in echoed) - - def test_out_of_range_then_valid(self) -> None: - """Out-of-range number prints error and loops until valid input.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", side_effect=["99", "1"]), - ): - pick_next_game([g1], state, config) - assert state.current_app_id == 1 - assert any("Out of range" in line for line in echoed) - - -class TestPickNextGameSequential: - """Tests for the on_select sequential branch of pick_next_game.""" - - @staticmethod - def _common_patches(echoed: list[str]) -> contextlib.ExitStack: - stack = contextlib.ExitStack() - stack.enter_context( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ) - ) - stack.enter_context( - patch( - "steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ) - ) - stack.enter_context( - patch( - "steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ) - ) - stack.enter_context( - patch( - "steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ) - ) - stack.enter_context(patch("steam_backlog_enforcer.config._atomic_write")) - return stack - - def test_on_select_accepts_pick(self) -> None: - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - with self._common_patches(echoed): - pick_next_game([g1], state, config, on_select=lambda _g: True) - assert state.current_app_id == 1 - - def test_on_select_rejection_records_skip_and_picks_next(self) -> None: - g1 = _game(app_id=1, name="G1", hours=5.0) - g2 = _game(app_id=2, name="G2", hours=6.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - calls: list[int] = [] - - def on_select(game: GameInfo) -> bool: - calls.append(game.app_id) - return game.app_id == 2 # reject g1, accept g2 - - with self._common_patches(echoed): - pick_next_game([g1, g2], state, config, on_select=on_select) - - assert calls == [1, 2] - assert state.current_app_id == 2 - assert "1" in state.skipped_until - assert any("Skipped G1 for 7 days" in line for line in echoed) - - def test_on_select_no_candidates(self) -> None: - """Sequential branch with no candidates clears state.""" - complete = _game(app_id=1, hours=1.0, total=10, unlocked=10) - config = Config(steam_api_key="k", steam_id="i") - state = State(current_app_id=99, current_game_name="X") - echoed: list[str] = [] - with self._common_patches(echoed): - pick_next_game([complete], state, config, on_select=lambda _g: True) - assert state.current_app_id is None - - def test_on_select_no_playable_branch(self) -> None: - """Sequential branch when all candidates lack Linux compatibility.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - return_value=None, - ), - patch( - "steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch("steam_backlog_enforcer.config._atomic_write"), - ): - pick_next_game([g1], state, config, on_select=lambda _g: True) - assert state.current_app_id is None - assert any("No playable games" in line for line in echoed) - - def test_on_select_no_confidence_branch(self) -> None: - """Sequential branch when all candidates fail HLTB confidence.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - g1.comp_100_count = 0 - g1.count_comp = 0 - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence", - ), - patch("steam_backlog_enforcer.config._atomic_write"), - ): - pick_next_game([g1], state, config, on_select=lambda _g: True) - assert state.current_app_id is None - assert any("No assignable games" in line for line in echoed) - - def test_enforcement_started_at_not_overwritten_when_set(self) -> None: - """enforcement_started_at is preserved when already populated.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") - existing_ts = "2024-01-01T00:00:00+00:00" - state = State(enforcement_started_at=existing_ts) - with self._common_patches([]): - pick_next_game([g1], state, config, on_select=lambda _g: True) - assert state.current_app_id == 1 - assert state.enforcement_started_at == existing_ts diff --git a/steam_backlog_enforcer/tests/test_scanning_part4.py b/steam_backlog_enforcer/tests/test_scanning_part4.py index 479130a..8f62609 100644 --- a/steam_backlog_enforcer/tests/test_scanning_part4.py +++ b/steam_backlog_enforcer/tests/test_scanning_part4.py @@ -4,15 +4,9 @@ from __future__ import annotations from unittest.mock import MagicMock, patch -from steam_backlog_enforcer._scanning_confidence import ( - _filter_hltb_confident_candidates, - _force_refresh_candidate_confidence, - _refresh_candidate_confidence_batch, -) from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.scanning import ( _collect_top_candidates, - _pick_next_shortest_candidate, do_check, ) from steam_backlog_enforcer.steam_api import GameInfo @@ -70,6 +64,7 @@ class TestCollectTopCandidates: assert conf_skip == 0 def test_empty_candidates(self) -> None: + """Test empty candidates.""" qualified, conf_skip, linux_skip = _collect_top_candidates([]) assert qualified == [] assert conf_skip == 0 @@ -93,11 +88,13 @@ class TestDoCheck: """Tests for do_check.""" def test_no_assignment(self) -> None: + """Test no assignment.""" with patch("steam_backlog_enforcer.scanning._echo") as mock_echo: do_check(Config(), State()) mock_echo.assert_called() def test_fetch_fails(self) -> None: + """Test fetch fails.""" mock_client = MagicMock() mock_client.refresh_single_game.return_value = None with ( @@ -110,217 +107,3 @@ class TestDoCheck: ): state = State(current_app_id=440, current_game_name="TF2") do_check(Config(steam_api_key="k", steam_id="i"), state) - - -class TestConfidenceHelpers: - """Coverage-focused tests for scanning confidence helper branches.""" - - def test_force_refresh_candidate_confidence_delegates(self) -> None: - game = _game(app_id=10, name="A") - with patch( - "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch", - ) as mock_batch: - _force_refresh_candidate_confidence(game) - mock_batch.assert_called_once_with([game], force=True) - - def test_refresh_candidate_confidence_batch_no_missing_skips_fetch(self) -> None: - game = _game(app_id=20, name="B", hours=12.0) - game.comp_100_count = 3 - game.count_comp = 15 - with patch( - "steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached", - ) as mock_fetch: - _refresh_candidate_confidence_batch([game], force=False) - mock_fetch.assert_not_called() - - def test_refresh_candidate_confidence_batch_preserves_existing_hours(self) -> None: - game = _game(app_id=30, name="C", hours=9.5) - game.comp_100_count = 0 - game.count_comp = 0 - with ( - patch( - "steam_backlog_enforcer._scanning_confidence.load_hltb_cache", - side_effect=[{30: 9.5}, {30: -1.0}], - ), - patch( - "steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache", - return_value={30: 0}, - ), - patch( - "steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache", - return_value={30: 0}, - ), - patch( - "steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached", - return_value={30: -1.0}, - ), - patch( - "steam_backlog_enforcer._scanning_confidence.save_hltb_cache", - ) as mock_save, - ): - _refresh_candidate_confidence_batch([game], force=True) - - assert game.completionist_hours == 9.5 - saved_cache = mock_save.call_args.args[0] - assert saved_cache[30] == 9.5 - - def test_filter_hltb_confident_candidates_skips_low_confidence(self) -> None: - low = _game(app_id=40, name="Low", hours=2.0) - low.comp_100_count = 1 - low.count_comp = 2 - with ( - patch( - "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch", - ), - patch("steam_backlog_enforcer._scanning_confidence._echo") as mock_echo, - ): - result = _filter_hltb_confident_candidates([low]) - assert result == [] - assert mock_echo.called - - def test_pick_next_shortest_candidate_logs_skipped_unplayable_batches(self) -> None: - bad = _game(app_id=50, name="Bad", hours=1.0) - good = _game(app_id=51, name="Good", hours=2.0) - bad.comp_100_count = 3 - bad.count_comp = 15 - good.comp_100_count = 3 - good.count_comp = 15 - - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=[None, good], - ), - patch("steam_backlog_enforcer.scanning._echo") as mock_echo, - ): - picked, skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( - [bad, good], - ) - - assert picked is good - assert skipped_low_conf == 0 - assert skipped_linux == 1 - assert any( - "Skipped 1 game(s) with poor Linux compatibility" in str(call) - for call in mock_echo.call_args_list - ) - - def test_pick_next_shortest_candidate_no_echo_when_linux_skipped_zero( - self, - ) -> None: - """Covers 419->423: no echo printed when linux_skipped == 0.""" - good = _game(app_id=51, name="Good", hours=2.0) - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - return_value=good, - ), - patch("steam_backlog_enforcer.scanning._echo") as mock_echo, - ): - picked, _skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( - [good], - ) - assert picked is good - assert skipped_linux == 0 - mock_echo.assert_not_called() - - def test_pick_next_shortest_candidate_skips_low_confidence(self) -> None: - """Covers lines 413-414: confidence_skipped += 1; continue.""" - low_conf = _game(app_id=10, name="Low", hours=1.0) - low_conf.comp_100_count = 0 - low_conf.count_comp = 0 - with ( - patch( - "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence" - ), - patch("steam_backlog_enforcer.scanning._echo"), - ): - picked, skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( - [low_conf], - ) - assert picked is None - assert skipped_low_conf == 1 - assert skipped_linux == 0 - - def test_pick_next_shortest_candidate_all_protondb_fail(self) -> None: - """Covers lines 426-428: linux_skipped > 0 after loop, return None.""" - g1 = _game(app_id=10, name="Borked", hours=1.0) - with ( - patch( - "steam_backlog_enforcer.scanning._pick_playable_candidate", - return_value=None, - ), - patch("steam_backlog_enforcer.scanning._echo") as mock_echo, - ): - picked, _skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( - [g1], - ) - assert picked is None - assert skipped_linux == 1 - assert any( - "Skipped 1 game(s) with poor Linux compatibility" in str(call) - for call in mock_echo.call_args_list - ) - - game = _game(app_id=440, name="TF2", total=5, unlocked=5) - mock_client = MagicMock() - mock_client.refresh_single_game.return_value = game - snap = [game.to_snapshot()] - with ( - patch( - "steam_backlog_enforcer.scanning.SteamAPIClient", - return_value=mock_client, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer.scanning.send_notification", - ), - patch( - "steam_backlog_enforcer.scanning.load_snapshot", - return_value=snap, - ), - patch( - "steam_backlog_enforcer.scanning.pick_next_game", - ), - patch("steam_backlog_enforcer.scanning.detect_tampering"), - ): - state = State(current_app_id=440, current_game_name="TF2") - do_check(Config(steam_api_key="k", steam_id="i"), state) - assert 440 in state.finished_app_ids - - def test_complete_no_snapshot(self) -> None: - game = _game(app_id=440, name="TF2", total=5, unlocked=5) - mock_client = MagicMock() - mock_client.refresh_single_game.return_value = game - with ( - patch( - "steam_backlog_enforcer.scanning.SteamAPIClient", - return_value=mock_client, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer.scanning.send_notification", - ), - patch( - "steam_backlog_enforcer.scanning.load_snapshot", - return_value=None, - ), - patch("steam_backlog_enforcer.scanning.detect_tampering"), - ): - state = State(current_app_id=440, current_game_name="TF2") - do_check(Config(steam_api_key="k", steam_id="i"), state) - - def test_not_complete(self) -> None: - game = _game(app_id=440, name="TF2", total=10, unlocked=5) - mock_client = MagicMock() - mock_client.refresh_single_game.return_value = game - with ( - patch( - "steam_backlog_enforcer.scanning.SteamAPIClient", - return_value=mock_client, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch("steam_backlog_enforcer.scanning.detect_tampering"), - ): - state = State(current_app_id=440, current_game_name="TF2") - do_check(Config(steam_api_key="k", steam_id="i"), state) diff --git a/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py b/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py new file mode 100644 index 0000000..16c354c --- /dev/null +++ b/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py @@ -0,0 +1,156 @@ +"""Tests for scanning module.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.scanning import ( + pick_next_game, +) +from steam_backlog_enforcer.steam_api import GameInfo + + +def _game( + app_id: int = 1, + name: str = "G", + total: int = 10, + unlocked: int = 0, + hours: float = -1, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=3, + count_comp=15, + ) + + +class TestPickNextGame: + """Tests for pick_next_game.""" + + def test_picks_shortest(self) -> None: + """Test picks shortest.""" + g1 = _game(app_id=1, name="Long", hours=100.0) + g2 = _game(app_id=2, name="Short", hours=10.0) + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch("steam_backlog_enforcer._scanning_confidence._echo"), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + patch("builtins.input", return_value="1"), + ): + pick_next_game([g1, g2], state, config) + assert state.current_app_id == 2 + + def test_no_candidates(self) -> None: + """Test no candidates.""" + g1 = _game(app_id=1, total=5, unlocked=5) + config = Config(steam_api_key="k", steam_id="i") + state = State() + with patch("steam_backlog_enforcer.scanning._echo"): + pick_next_game([g1], state, config) + assert state.current_app_id is None + + def test_skips_finished(self) -> None: + """Test skips finished.""" + g1 = _game(app_id=1, name="G1", hours=10.0) + g2 = _game(app_id=2, name="G2", hours=20.0) + config = Config(steam_api_key="k", steam_id="i") + state = State(finished_app_ids=[1]) + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + patch("builtins.input", return_value="1"), + ): + pick_next_game([g1, g2], state, config) + assert state.current_app_id == 2 + + def test_no_playable(self) -> None: + """Test no playable.""" + g1 = _game(app_id=1, name="G1") + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + return_value=None, + ), + patch("steam_backlog_enforcer.scanning._echo"), + ): + pick_next_game([g1], state, config) + assert state.current_app_id is None + + def test_uninstalls_others(self) -> None: + """Test uninstalls others.""" + g1 = _game(app_id=1, name="G1", hours=10.0) + config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=True) + state = State() + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch("steam_backlog_enforcer._scanning_confidence._echo"), + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=2, + ), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch("builtins.input", return_value="1"), + ): + pick_next_game([g1], state, config) + assert state.current_app_id == 1 + + def test_auto_installs(self) -> None: + """Test auto installs.""" + g1 = _game(app_id=1, name="G1", hours=10.0) + config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=False) + state = State() + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch("steam_backlog_enforcer._scanning_confidence._echo"), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=False, + ), + patch("steam_backlog_enforcer.scanning.install_game") as mock_install, + patch("builtins.input", return_value="1"), + ): + pick_next_game([g1], state, config) + mock_install.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_scanning_pick_next_game_sequential.py b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_sequential.py new file mode 100644 index 0000000..b893a0b --- /dev/null +++ b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_sequential.py @@ -0,0 +1,164 @@ +"""Tests for scanning module (part 3): TestPickNextGame continued.""" + +from __future__ import annotations + +import contextlib +from unittest.mock import patch + +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.scanning import pick_next_game +from steam_backlog_enforcer.steam_api import GameInfo + + +def _game( + app_id: int = 1, + name: str = "G", + total: int = 10, + unlocked: int = 0, + hours: float = -1, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=3, + count_comp=15, + ) + + +class TestPickNextGameSequential: + """Tests for the on_select sequential branch of pick_next_game.""" + + @staticmethod + def _common_patches(echoed: list[str]) -> contextlib.ExitStack: + """Test common patches.""" + stack = contextlib.ExitStack() + stack.enter_context( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ) + ) + stack.enter_context( + patch( + "steam_backlog_enforcer.scanning._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ) + ) + stack.enter_context( + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ) + ) + stack.enter_context( + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ) + ) + stack.enter_context(patch("steam_backlog_enforcer.config._atomic_write")) + return stack + + def test_on_select_accepts_pick(self) -> None: + """Test on select accepts pick.""" + g1 = _game(app_id=1, name="G1", hours=5.0) + config = Config(steam_api_key="k", steam_id="i") + state = State() + echoed: list[str] = [] + with self._common_patches(echoed): + pick_next_game([g1], state, config, on_select=lambda _g: True) + assert state.current_app_id == 1 + + def test_on_select_rejection_records_skip_and_picks_next(self) -> None: + """Test on select rejection records skip and picks next.""" + g1 = _game(app_id=1, name="G1", hours=5.0) + g2 = _game(app_id=2, name="G2", hours=6.0) + config = Config(steam_api_key="k", steam_id="i") + state = State() + echoed: list[str] = [] + calls: list[int] = [] + + def on_select(game: GameInfo) -> bool: + """Test on select.""" + calls.append(game.app_id) + return game.app_id == 2 # reject g1, accept g2 + + with self._common_patches(echoed): + pick_next_game([g1, g2], state, config, on_select=on_select) + + assert calls == [1, 2] + assert state.current_app_id == 2 + assert "1" in state.skipped_until + assert any("Skipped G1 for 7 days" in line for line in echoed) + + def test_on_select_no_candidates(self) -> None: + """Sequential branch with no candidates clears state.""" + complete = _game(app_id=1, hours=1.0, total=10, unlocked=10) + config = Config(steam_api_key="k", steam_id="i") + state = State(current_app_id=99, current_game_name="X") + echoed: list[str] = [] + with self._common_patches(echoed): + pick_next_game([complete], state, config, on_select=lambda _g: True) + assert state.current_app_id is None + + def test_on_select_no_playable_branch(self) -> None: + """Sequential branch when all candidates lack Linux compatibility.""" + g1 = _game(app_id=1, name="G1", hours=5.0) + config = Config(steam_api_key="k", steam_id="i") + state = State() + echoed: list[str] = [] + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + return_value=None, + ), + patch( + "steam_backlog_enforcer.scanning._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch("steam_backlog_enforcer.config._atomic_write"), + ): + pick_next_game([g1], state, config, on_select=lambda _g: True) + assert state.current_app_id is None + assert any("No playable games" in line for line in echoed) + + def test_on_select_no_confidence_branch(self) -> None: + """Sequential branch when all candidates fail HLTB confidence.""" + g1 = _game(app_id=1, name="G1", hours=5.0) + g1.comp_100_count = 0 + g1.count_comp = 0 + config = Config(steam_api_key="k", steam_id="i") + state = State() + echoed: list[str] = [] + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch( + "steam_backlog_enforcer.scanning._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch( + "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence", + ), + patch("steam_backlog_enforcer.config._atomic_write"), + ): + pick_next_game([g1], state, config, on_select=lambda _g: True) + assert state.current_app_id is None + assert any("No assignable games" in line for line in echoed) + + def test_enforcement_started_at_not_overwritten_when_set(self) -> None: + """enforcement_started_at is preserved when already populated.""" + g1 = _game(app_id=1, name="G1", hours=5.0) + config = Config(steam_api_key="k", steam_id="i") + existing_ts = "2024-01-01T00:00:00+00:00" + state = State(enforcement_started_at=existing_ts) + with self._common_patches([]): + pick_next_game([g1], state, config, on_select=lambda _g: True) + assert state.current_app_id == 1 + assert state.enforcement_started_at == existing_ts diff --git a/steam_backlog_enforcer/tests/test_scanning_pick_next_game_unknown_hours.py b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_unknown_hours.py new file mode 100644 index 0000000..2d90c1f --- /dev/null +++ b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_unknown_hours.py @@ -0,0 +1,152 @@ +"""Tests for scanning module.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.scanning import ( + pick_next_game, +) +from steam_backlog_enforcer.steam_api import GameInfo + + +def _game( + app_id: int = 1, + name: str = "G", + total: int = 10, + unlocked: int = 0, + hours: float = -1, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=3, + count_comp=15, + ) + + +class TestPickNextGameGroup2: + """Tests for pick_next_game.""" + + def test_unknown_hours(self) -> None: + """Test unknown hours.""" + g1 = _game(app_id=1, name="G1", hours=-1) + g2 = _game(app_id=2, name="G2", hours=10.0) + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + patch("builtins.input", return_value="1"), + ): + pick_next_game([g1, g2], state, config) + assert state.current_app_id == 2 + + def test_picks_game_no_hours(self) -> None: + """Chosen game has no HLTB hours — covers no-hours output branch.""" + g1 = _game(app_id=1, name="G1", hours=-1) + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("steam_backlog_enforcer.scanning._echo"), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + patch("builtins.input", return_value="1"), + ): + pick_next_game([g1], state, config) + assert state.current_app_id == 1 + + def test_skips_low_confidence_and_picks_next(self) -> None: + """Test skips low confidence and picks next.""" + low = _game(app_id=1, name="LowConfidence", hours=1.0) + low.comp_100_count = 1 + low.count_comp = 5 + valid = _game(app_id=2, name="ValidConfidence", hours=2.0) + valid.comp_100_count = 3 + valid.count_comp = 15 + echoed: list[str] = [] + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch( + "steam_backlog_enforcer.scanning._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch( + "steam_backlog_enforcer._scanning_confidence._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch( + "steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + patch("builtins.input", return_value="1"), + ): + pick_next_game([low, valid], state, config) + assert state.current_app_id == 2 + assert any("Skipping LowConfidence" in line for line in echoed) + assert any("comp_100 polls 1 < 3" in line for line in echoed) + + def test_all_candidates_filtered_by_confidence(self) -> None: + """Test all candidates filtered by confidence.""" + low_a = _game(app_id=1, name="LowA", hours=1.0) + low_a.comp_100_count = 2 + low_a.count_comp = 15 + low_b = _game(app_id=2, name="LowB", hours=2.0) + low_b.comp_100_count = 3 + low_b.count_comp = 14 + echoed: list[str] = [] + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "steam_backlog_enforcer.scanning._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch( + "steam_backlog_enforcer._scanning_confidence._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch( + "steam_backlog_enforcer.scanning._pick_playable_candidate", + return_value=None, + ) as mock_pick, + ): + pick_next_game([low_a, low_b], state, config) + assert state.current_app_id is None + mock_pick.assert_not_called() + assert any("No assignable games found" in line for line in echoed) diff --git a/steam_backlog_enforcer/tests/test_stats.py b/steam_backlog_enforcer/tests/test_stats.py index 5211e8a..baa3066 100644 --- a/steam_backlog_enforcer/tests/test_stats.py +++ b/steam_backlog_enforcer/tests/test_stats.py @@ -2,26 +2,15 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone from unittest.mock import patch from steam_backlog_enforcer._stats import ( - _ensure_completed_rush_data, - _ensure_rush_data, _filter_qualifying_games, - _format_completion_date, _GameTimes, - _print_pace_scenario, - _print_player_speed_scenario, - _print_scenario, - _print_worst_example, - _sum_hours, - cmd_stats, ) -from steam_backlog_enforcer._web_dataset import PaceVsHLTB -from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.config import State from steam_backlog_enforcer.protondb import ProtonDBRating -from steam_backlog_enforcer.steam_api import GameInfo, SteamAPIError +from steam_backlog_enforcer.steam_api import GameInfo _PKG = "steam_backlog_enforcer._stats" @@ -60,6 +49,7 @@ class TestFilterQualifyingGames: leisure_cache: dict[int, float] | None = None, game_id_cache: dict[int, int] | None = None, ) -> tuple[list[_GameTimes], int, int, int]: + """Test run.""" with ( patch(f"{_PKG}.load_hltb_rush_cache", return_value=rush_cache or {}), patch( @@ -78,6 +68,7 @@ class TestFilterQualifyingGames: return _filter_qualifying_games(games, state) def test_current_app_id_excluded(self) -> None: + """Test current app id excluded.""" state = State(current_app_id=1) g1 = _game(app_id=1) g2 = _game(app_id=2) @@ -94,6 +85,7 @@ class TestFilterQualifyingGames: assert len(qualified) == 1 def test_finished_app_ids_excluded(self) -> None: + """Test finished app ids excluded.""" state = State() state.finished_app_ids = [1] g1 = _game(app_id=1) @@ -111,6 +103,7 @@ class TestFilterQualifyingGames: assert qualified[0].game.app_id == 2 def test_low_confidence_counts_hltb_skipped(self) -> None: + """Test low confidence counts hltb skipped.""" state = State() g = _game(app_id=1) with ( @@ -141,932 +134,3 @@ class TestFilterQualifyingGames: ): _filter_qualifying_games([g], state) mock_proton.assert_not_called() - - def test_unplayable_rating_counts_linux_skipped(self) -> None: - state = State() - g = _game(app_id=1) - ratings = {1: _unplayable_rating(1)} - with ( - patch(f"{_PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{_PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{_PKG}._apply_cached_confidence_to_candidates"), - patch(f"{_PKG}._refresh_candidate_confidence_batch"), - patch(f"{_PKG}._confidence_fail_reasons", return_value=[]), - patch(f"{_PKG}.fetch_protondb_ratings", return_value=ratings), - ): - qualified, _, linux_skip, _ = _filter_qualifying_games([g], state) - assert linux_skip == 1 - assert len(qualified) == 0 - - def test_no_data_counts_no_data_skipped(self) -> None: - """Game with all -1 hours is counted as no_data_skipped.""" - state = State() - g = _game(app_id=1, hours=-1.0) - qualified, _, _, no_data_skip = self._run([g], state) - assert no_data_skip == 1 - assert len(qualified) == 0 - - def test_worst_hours_positive_when_completionist_hours_positive(self) -> None: - state = State() - g = _game(app_id=1, hours=25.0) - qualified, _, _, _ = self._run([g], state, rush_cache={1: 10.0}) - assert qualified[0].worst_hours == 25.0 - - def test_worst_hours_from_leisure_when_completionist_zero(self) -> None: - """worst_hours falls back to leisure_100h when completionist_hours is zero.""" - state = State() - g = _game(app_id=1, hours=0.0) - qualified, _, _, _ = self._run( - [g], state, rush_cache={1: 5.0}, leisure_cache={1: 6.0} - ) - assert qualified[0].worst_hours == 6.0 - - def test_worst_hours_is_max_when_leisure_exceeds_completionist(self) -> None: - """worst_hours is max(completionist, leisure_100h) when leisure is higher.""" - state = State() - g = _game(app_id=1, hours=25.0) - qualified, _, _, _ = self._run( - [g], state, rush_cache={1: 10.0}, leisure_cache={1: 40.0} - ) - assert qualified[0].worst_hours == 40.0 - - def test_worst_hours_negative_when_all_zero(self) -> None: - """worst_hours = -1 when both completionist_hours and leisure_100h are zero.""" - state = State() - g = _game(app_id=1, hours=0.0) - qualified, _, _, _ = self._run([g], state, rush_cache={1: 5.0}) - assert qualified[0].worst_hours == -1 - - def test_rush_and_leisure_from_cache(self) -> None: - state = State() - g = _game(app_id=1, hours=30.0) - qualified, _, _, _ = self._run( - [g], state, rush_cache={1: 12.0}, leisure_cache={1: 40.0} - ) - assert qualified[0].rush_hours == 12.0 - assert qualified[0].leisure_100h == 40.0 - - def test_missing_cache_entry_defaults_to_minus_one(self) -> None: - state = State() - g = _game(app_id=1, hours=20.0) - qualified, _, _, _ = self._run([g], state) - assert qualified[0].rush_hours == -1 - assert qualified[0].leisure_100h == -1 - - def test_only_rush_nonzero_qualifies(self) -> None: - """Game qualifies if only rush_hours is positive (worst <= 0, leisure <= 0).""" - state = State() - g = _game(app_id=1, hours=-1.0) - qualified, _, _, no_data_skip = self._run([g], state, rush_cache={1: 8.0}) - assert no_data_skip == 0 - assert len(qualified) == 1 - - def test_game_id_populated_from_cache(self) -> None: - """hltb_game_id is taken from game_id_cache.""" - state = State() - g = _game(app_id=1, hours=20.0) - qualified, _, _, _ = self._run([g], state, game_id_cache={1: 57514}) - assert qualified[0].hltb_game_id == 57514 - - def test_game_id_defaults_to_zero_when_not_in_cache(self) -> None: - """hltb_game_id defaults to 0 when not in cache.""" - state = State() - g = _game(app_id=1, hours=20.0) - qualified, _, _, _ = self._run([g], state) - assert qualified[0].hltb_game_id == 0 - - -class TestSumHours: - """Tests for _sum_hours.""" - - def _make_entry(self, worst: float, rush: float, leisure: float) -> _GameTimes: - return _GameTimes( - game=_game(), worst_hours=worst, rush_hours=rush, leisure_100h=leisure - ) - - def test_empty_list(self) -> None: - total, missing = _sum_hours([], "worst_hours") - assert total == 0.0 - assert missing == 0 - - def test_all_positive(self) -> None: - entries = [ - self._make_entry(10.0, 8.0, 12.0), - self._make_entry(20.0, 15.0, 25.0), - ] - total, missing = _sum_hours(entries, "worst_hours") - assert total == 30.0 - assert missing == 0 - - def test_some_negative(self) -> None: - entries = [ - self._make_entry(10.0, -1.0, 12.0), - self._make_entry(-1.0, 8.0, 25.0), - ] - total, missing = _sum_hours(entries, "worst_hours") - assert total == 10.0 - assert missing == 1 - - def test_all_negative(self) -> None: - entries = [self._make_entry(-1.0, -1.0, -1.0)] - total, missing = _sum_hours(entries, "rush_hours") - assert total == 0.0 - assert missing == 1 - - -class TestFormatCompletionDate: - """Tests for _format_completion_date.""" - - def test_zero_hours_returns_na(self) -> None: - assert _format_completion_date(0.0, 4.0) == "N/A" - - def test_negative_hours_returns_na(self) -> None: - assert _format_completion_date(-5.0, 4.0) == "N/A" - - def test_zero_daily_hours_returns_na(self) -> None: - assert _format_completion_date(100.0, 0.0) == "N/A" - - def test_negative_daily_hours_returns_na(self) -> None: - assert _format_completion_date(100.0, -1.0) == "N/A" - - def test_normal_returns_days_and_date(self) -> None: - result = _format_completion_date(40.0, 4.0) - # 40 / 4 = 10 days - assert result.startswith("10 days (") - assert ")" in result - - -class TestPrintScenario: - """Tests for _print_scenario.""" - - def test_no_data_prints_no_data_message(self) -> None: - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_scenario("2. RUSH", 0.0, 0, 5) - assert any("No data available" in s for s in echoed) - - def test_with_data_no_missing(self) -> None: - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_scenario("2. RUSH", 100.0, 0, 5) - assert any("Total:" in s for s in echoed) - assert not any("had no data" in s for s in echoed) - - def test_with_data_and_missing(self) -> None: - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_scenario("2. RUSH", 100.0, 2, 5) - assert any("had no data" in s for s in echoed) - - -class TestPrintPaceScenario: - """Tests for _print_pace_scenario.""" - - def test_no_start_date(self) -> None: - state = State() - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_pace_scenario(state, 10, 0) - assert any("No start date recorded" in s for s in echoed) - - def test_invalid_start_date(self) -> None: - state = State(enforcement_started_at="not-a-date") - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_pace_scenario(state, 10, 0) - assert any("Invalid enforcement_started_at" in s for s in echoed) - - def test_no_games_finished(self) -> None: - started = datetime.now(timezone.utc) - timedelta(days=30) - state = State(enforcement_started_at=started.isoformat()) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_pace_scenario(state, 10, 0) - assert any("No games finished yet" in s for s in echoed) - - def test_normal_pace(self) -> None: - started = datetime.now(timezone.utc) - timedelta(days=60) - state = State(enforcement_started_at=started.isoformat()) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_pace_scenario(state, 5, 3) - assert any("Pace:" in s for s in echoed) - assert any("Est. complete:" in s for s in echoed) - - -class TestCmdStats: - """Tests for cmd_stats.""" - - def _config(self) -> Config: - return Config(steam_api_key="k", steam_id="i") - - def test_no_snapshot(self) -> None: - echoed: list[str] = [] - state = State() - with ( - patch(f"{_PKG}.load_snapshot", return_value=None), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - cmd_stats(self._config(), state) - assert any("No snapshot found" in s for s in echoed) - - def _snapshot_game(self, app_id: int = 1, hours: float = 20.0) -> dict[str, object]: - return { - "app_id": app_id, - "name": f"Game{app_id}", - "total_achievements": 10, - "unlocked_achievements": 0, - "playtime_minutes": 60, - "completionist_hours": hours, - "comp_100_count": 5, - "count_comp": 20, - } - - def _run_cmd_stats( - self, - state: State, - hltb_skip: int = 0, - linux_skip: int = 0, - no_data_skip: int = 0, - ) -> list[str]: - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) - echoed: list[str] = [] - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], hltb_skip, linux_skip, no_data_skip), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - ): - cmd_stats(self._config(), state) - return echoed - - def test_with_no_current_game(self) -> None: - state = State() - echoed = self._run_cmd_stats(state) - assert any("Qualifying games" in s for s in echoed) - assert not any("Current game:" in s for s in echoed) - - def test_with_current_game(self) -> None: - state = State(current_app_id=42, current_game_name="Hollow Knight") - echoed = self._run_cmd_stats(state) - assert any("Current game:" in s and "Hollow Knight" in s for s in echoed) - - def test_hltb_skipped_shown(self) -> None: - state = State() - echoed = self._run_cmd_stats(state, hltb_skip=3) - assert any("HLTB-skipped" in s for s in echoed) - - def test_linux_skipped_shown(self) -> None: - state = State() - echoed = self._run_cmd_stats(state, linux_skip=2) - assert any("Linux-skipped" in s for s in echoed) - - def test_no_data_skipped_shown(self) -> None: - state = State() - echoed = self._run_cmd_stats(state, no_data_skip=1) - assert any("No-data-skipped" in s for s in echoed) - - def test_zero_skips_not_shown(self) -> None: - state = State() - echoed = self._run_cmd_stats(state) - assert not any("HLTB-skipped" in s for s in echoed) - assert not any("Linux-skipped" in s for s in echoed) - assert not any("No-data-skipped" in s for s in echoed) - - def test_finished_games_count_uses_snapshot_complete(self) -> None: - """'Finished games' count uses snapshot is_complete, not finished_app_ids.""" - state = State() - # finished_app_ids has 1 entry, but snapshot has 2 complete games — count = 2. - state.finished_app_ids = [99] - snapshot_complete = { - **self._snapshot_game(app_id=2), - "unlocked_achievements": 10, - } - snapshot = [self._snapshot_game(app_id=1), snapshot_complete] - game = GameInfo.from_snapshot(self._snapshot_game()) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) - echoed: list[str] = [] - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - ): - cmd_stats(self._config(), state) - assert any("Finished games" in s and "1" in s for s in echoed) - - def test_detail_data_complete_message_shown(self) -> None: - """'Detail data: ...' shown when all qualifying games have rush hours.""" - state = State() - echoed = self._run_cmd_stats(state) - # entry has rush_hours=15.0 > 0, so missing_rush_final == 0 and total_q == 1 - assert any("Detail data" in s for s in echoed) - - def test_note_missing_rush_shown_when_rush_absent(self) -> None: - """'Note: X games still missing...' shown when rush_hours <= 0 after fetch.""" - state = State() - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=-1.0, leisure_100h=-1.0 - ) - echoed: list[str] = [] - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._ensure_rush_data", return_value=False), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), - ): - cmd_stats(self._config(), state) - assert any("still missing" in s for s in echoed) - - def test_no_detail_message_when_no_qualifying_games(self) -> None: - """Neither 'Note' nor 'Detail data' shown when qualified list is empty.""" - state = State() - snapshot = [self._snapshot_game()] - echoed: list[str] = [] - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._ensure_rush_data", return_value=False), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), - ): - cmd_stats(self._config(), state) - assert not any("Detail data" in s for s in echoed) - assert not any("still missing" in s for s in echoed) - - def test_refilter_called_when_ensure_rush_data_returns_true(self) -> None: - """_filter_qualifying_games called twice when _ensure_rush_data returns True.""" - state = State() - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) - filter_calls: list[int] = [] - - def count_filter( - _games: object, _state: object - ) -> tuple[list[_GameTimes], int, int, int]: - filter_calls.append(1) - return [entry], 0, 0, 0 - - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch(f"{_PKG}._filter_qualifying_games", side_effect=count_filter), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._ensure_rush_data", return_value=True), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo"), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), - ): - cmd_stats(self._config(), state) - assert len(filter_calls) == 2 - - def test_games_done_since_start_passed_to_pace(self) -> None: - """_print_pace_scenario gets only games completed after started_at.""" - from datetime import datetime, timezone - - started = datetime(2026, 1, 1, tzinfo=timezone.utc) - state = State(enforcement_started_at=started.isoformat()) - - after_ts = int(datetime(2026, 3, 1, tzinfo=timezone.utc).timestamp()) - before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp()) - - def _ach(ts: int) -> dict[str, object]: - return { - "api_name": "A", - "display_name": "A", - "achieved": True, - "unlock_time": ts, - } - - # app_id=2: completed AFTER enforcement start → should count - snapshot_after = { - **self._snapshot_game(app_id=2), - "unlocked_achievements": 10, - "achievements": [_ach(after_ts)] * 10, - } - # app_id=3: completed BEFORE enforcement start → should NOT count - snapshot_before = { - **self._snapshot_game(app_id=3), - "unlocked_achievements": 10, - "achievements": [_ach(before_ts)] * 10, - } - snapshot = [self._snapshot_game(app_id=1), snapshot_after, snapshot_before] - game = GameInfo.from_snapshot(self._snapshot_game()) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) - captured: dict[str, int] = {} - - def capture_pace(_state: object, _remaining: object, games_done: int) -> None: - captured["games_done"] = games_done - - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo"), - patch(f"{_PKG}._print_pace_scenario", side_effect=capture_pace), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), - ): - cmd_stats(self._config(), state) - assert captured["games_done"] == 1 # only the post-start game - - def test_player_speed_scenario_called_with_pace_and_totals(self) -> None: - """_print_player_speed_scenario receives pace, rush_total, and leisure_total.""" - state = State() - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) - pace = PaceVsHLTB( - calibration_count=5, - ratio_vs_rush=1.1, - ratio_vs_leisure=0.4, - interpolation_t=0.05, - player_style="rush_to_leisure", - ) - captured: dict[str, object] = {} - - def capture_player_speed(p: object, rush: float, leisure: float) -> None: - captured["pace"] = p - captured["rush"] = rush - captured["leisure"] = leisure - - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}.compute_pace_vs_hltb", return_value=pace), - patch( - f"{_PKG}._print_player_speed_scenario", - side_effect=capture_player_speed, - ), - patch(f"{_PKG}._echo"), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), - ): - cmd_stats(self._config(), state) - assert captured["pace"] is pace - assert captured["rush"] == 15.0 - assert captured["leisure"] == 25.0 - - -class TestEnsureRushData: - """Tests for _ensure_rush_data.""" - - def _entry(self, rush: float) -> _GameTimes: - return _GameTimes( - game=_game(), worst_hours=10.0, rush_hours=rush, leisure_100h=5.0 - ) - - def test_empty_qualified_returns_false(self) -> None: - with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch: - result = _ensure_rush_data([]) - assert result is False - mock_fetch.assert_not_called() - - def test_all_have_rush_returns_false(self) -> None: - entries = [self._entry(10.0), self._entry(5.0)] - with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch: - result = _ensure_rush_data(entries) - assert result is False - mock_fetch.assert_not_called() - - def test_missing_rush_fetches_and_returns_true(self) -> None: - entries = [self._entry(-1.0)] - with ( - patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch, - patch(f"{_PKG}._echo"), - ): - result = _ensure_rush_data(entries) - assert result is True - mock_fetch.assert_called_once() - - -class TestEnsureCompletedRushData: - """Tests for _ensure_completed_rush_data.""" - - def _complete(self, app_id: int = 1, playtime: int = 600) -> GameInfo: - return GameInfo( - app_id=app_id, - name="Done", - total_achievements=10, - unlocked_achievements=10, - playtime_minutes=playtime, - completionist_hours=0.0, - comp_100_count=5, - count_comp=20, - ) - - def test_no_complete_games_returns_false_without_fetch(self) -> None: - incomplete = _game(app_id=1, total=10, unlocked=0) - with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch: - result = _ensure_completed_rush_data([incomplete]) - assert result is False - mock_fetch.assert_not_called() - - def test_complete_game_with_zero_playtime_excluded(self) -> None: - """Games with playtime_minutes=0 are skipped (no calibration value).""" - no_play = self._complete(playtime=0) - with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch: - result = _ensure_completed_rush_data([no_play]) - assert result is False - mock_fetch.assert_not_called() - - def test_complete_game_with_playtime_fetches(self) -> None: - game = self._complete() - with ( - patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=1) as mock_fetch, - patch(f"{_PKG}._echo"), - ): - result = _ensure_completed_rush_data([game]) - assert result is True - mock_fetch.assert_called_once_with([(1, "Done")]) - - def test_fetch_returns_zero_means_no_new_data(self) -> None: - """When fetch_hltb_detail_missing returns 0, return False (all cached).""" - game = self._complete() - with ( - patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0), - patch(f"{_PKG}._echo"), - ): - result = _ensure_completed_rush_data([game]) - assert result is False - - -class TestPrintPlayerSpeedScenario: - """Tests for _print_player_speed_scenario — 100 % branch coverage.""" - - def _echoed( - self, - pace: PaceVsHLTB | None, - rush: float = 100.0, - leisure: float = 200.0, - ) -> list[str]: - out: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: out.append(a[0])): - _print_player_speed_scenario(pace, rush, leisure) - return out - - def test_none_pace_shows_no_calibration_message(self) -> None: - echoed = self._echoed(None) - assert any("No calibration data" in s for s in echoed) - - def test_zero_calibration_count_shows_no_calibration_message(self) -> None: - pace = PaceVsHLTB( - calibration_count=0, - ratio_vs_rush=-1.0, - ratio_vs_leisure=-1.0, - interpolation_t=-1.0, - player_style="unknown", - ) - echoed = self._echoed(pace) - assert any("No calibration data" in s for s in echoed) - - def test_ratio_vs_rush_shown_when_positive(self) -> None: - pace = PaceVsHLTB( - calibration_count=5, - ratio_vs_rush=1.05, - ratio_vs_leisure=-1.0, - interpolation_t=-1.0, - player_style="unknown", - ) - echoed = self._echoed(pace) - assert any("rush pace" in s for s in echoed) - - def test_ratio_vs_leisure_shown_when_positive(self) -> None: - pace = PaceVsHLTB( - calibration_count=5, - ratio_vs_rush=1.05, - ratio_vs_leisure=0.5, - interpolation_t=-1.0, - player_style="unknown", - ) - echoed = self._echoed(pace) - assert any("leisure pace" in s for s in echoed) - - def test_interpolation_t_shown_when_not_minus_one(self) -> None: - pace = PaceVsHLTB( - calibration_count=5, - ratio_vs_rush=1.05, - ratio_vs_leisure=0.5, - interpolation_t=0.1, - player_style="rush_to_leisure", - ) - echoed = self._echoed(pace) - assert any("Interpolation t" in s for s in echoed) - - def test_estimate_uses_interpolation_when_available(self) -> None: - # rush=100, leisure=200, t=0.5 → est=150 - pace = PaceVsHLTB( - calibration_count=5, - ratio_vs_rush=1.5, - ratio_vs_leisure=0.5, - interpolation_t=0.5, - player_style="rush_to_leisure", - ) - echoed = self._echoed(pace, rush=100.0, leisure=200.0) - assert any("150" in s for s in echoed) - - def test_estimate_falls_back_to_ratio_when_no_interpolation(self) -> None: - # interpolation_t=-1, ratio_vs_rush=2.0, rush=100 → est=200 - pace = PaceVsHLTB( - calibration_count=5, - ratio_vs_rush=2.0, - ratio_vs_leisure=-1.0, - interpolation_t=-1.0, - player_style="unknown", - ) - echoed = self._echoed(pace, rush=100.0, leisure=0.0) - assert any("200" in s for s in echoed) - - def test_no_estimate_when_both_methods_unavailable(self) -> None: - """No 'Estimated backlog total' line when t=-1 and ratio=-1.""" - pace = PaceVsHLTB( - calibration_count=5, - ratio_vs_rush=-1.0, - ratio_vs_leisure=-1.0, - interpolation_t=-1.0, - player_style="unknown", - ) - echoed = self._echoed(pace, rush=100.0, leisure=0.0) - assert not any("Estimated backlog total" in s for s in echoed) - - def test_no_estimate_when_rush_total_zero_and_no_interpolation(self) -> None: - """No estimate line when rush_total=0 and interpolation_t=-1.""" - pace = PaceVsHLTB( - calibration_count=5, - ratio_vs_rush=1.5, - ratio_vs_leisure=-1.0, - interpolation_t=-1.0, - player_style="unknown", - ) - echoed = self._echoed(pace, rush=0.0, leisure=0.0) - assert not any("Estimated backlog total" in s for s in echoed) - - -class TestPrintWorstExample: - """Tests for _print_worst_example.""" - - def test_empty_list_does_nothing(self) -> None: - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([]) - assert echoed == [] - - def test_example_with_rush_and_leisure(self) -> None: - entry = _GameTimes( - game=_game(name="Portal"), - worst_hours=15.0, - rush_hours=5.0, - leisure_100h=20.0, - hltb_game_id=99999, - ) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([entry]) - assert any("Portal" in s for s in echoed) - assert any("Rush" in s for s in echoed) - assert any("Leisure" in s for s in echoed) - - def test_example_without_rush(self) -> None: - entry = _GameTimes( - game=_game(name="X"), - worst_hours=15.0, - rush_hours=-1.0, - leisure_100h=20.0, - hltb_game_id=99999, - ) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([entry]) - assert not any("Rush" in s for s in echoed) - assert any("Leisure" in s for s in echoed) - - def test_example_without_leisure(self) -> None: - entry = _GameTimes( - game=_game(name="Y"), - worst_hours=15.0, - rush_hours=5.0, - leisure_100h=-1.0, - hltb_game_id=99999, - ) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([entry]) - assert any("Rush" in s for s in echoed) - assert not any("Leisure" in s for s in echoed) - - def test_hltb_search_url_shown_when_lookup_finds_nothing(self) -> None: - """Falls back to search URL when hltb_game_id is 0 and lookup finds nothing.""" - entry = _GameTimes( - game=_game(name="Portal 2"), - worst_hours=15.0, - rush_hours=-1.0, - leisure_100h=-1.0, - ) - echoed: list[str] = [] - with ( - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0), - patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}), - ): - _print_worst_example([entry]) - assert any("howlongtobeat.com" in s and "Portal+2" in s for s in echoed) - - def test_hltb_direct_link_shown_after_on_demand_lookup(self) -> None: - """Direct link shown when on-demand lookup successfully finds the game ID.""" - entry = _GameTimes( - game=_game(app_id=111, name="Portal 2"), - worst_hours=15.0, - rush_hours=-1.0, - leisure_100h=-1.0, - ) - echoed: list[str] = [] - with ( - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0), - patch(f"{_PKG}.load_hltb_game_id_cache", return_value={111: 42000}), - ): - _print_worst_example([entry]) - assert any("howlongtobeat.com/game/42000" in s for s in echoed) - assert not any("?q=" in s for s in echoed) - - def test_hltb_direct_link_shown_when_game_id_known(self) -> None: - """Direct HLTB game link shown when hltb_game_id is populated.""" - entry = _GameTimes( - game=_game(name="Devil May Cry 5"), - worst_hours=186.0, - rush_hours=50.0, - leisure_100h=186.0, - hltb_game_id=57514, - ) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([entry]) - assert any("howlongtobeat.com/game/57514" in s for s in echoed) - assert not any("?q=" in s for s in echoed) - - def test_entries_with_zero_worst_hours_excluded_from_examples(self) -> None: - """Games with worst_hours <= 0 are not selected as the example.""" - bad = _GameTimes( - game=_game(name="Skip"), worst_hours=0.0, rush_hours=-1.0, leisure_100h=-1.0 - ) - good = _GameTimes( - game=_game(name="Pick"), - worst_hours=10.0, - rush_hours=-1.0, - leisure_100h=-1.0, - hltb_game_id=99999, - ) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([bad, good]) - assert any("Pick" in s for s in echoed) - assert not any("Skip" in s for s in echoed) - - -class TestRefreshRecentlyPlayedCompletions: - """Tests for _refresh_recently_played_completions.""" - - def test_oserror_on_stat_returns_games_unchanged(self) -> None: - games = [GameInfo(1, "G", 10, 0, 60)] - with patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf: - mock_sf.stat.side_effect = OSError("no file") - from steam_backlog_enforcer._stats import ( - _refresh_recently_played_completions, - ) - - result = _refresh_recently_played_completions(games, Config()) - assert result == games - - def test_steam_api_error_returns_games_unchanged(self) -> None: - """A SteamAPIError while fetching owned games is swallowed.""" - games = [GameInfo(1, "G", 10, 0, 60)] - with ( - patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf, - patch(f"{_PKG}.SteamAPIClient") as mock_cls, - ): - mock_sf.stat.return_value.st_mtime = 1_000_000.0 - mock_cls.return_value.get_owned_games.side_effect = SteamAPIError( - "api down" - ) - from steam_backlog_enforcer._stats import ( - _refresh_recently_played_completions, - ) - - result = _refresh_recently_played_completions(games, Config()) - assert result == games - - def test_no_recently_played_returns_games_unchanged(self) -> None: - games = [GameInfo(1, "G", 10, 0, 60)] - with ( - patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf, - patch(f"{_PKG}.SteamAPIClient") as mock_cls, - ): - mock_sf.stat.return_value.st_mtime = 1_000_000.0 - mock_cls.return_value.get_owned_games.return_value = [ - {"appid": 1, "rtime_last_played": 500_000} - ] - from steam_backlog_enforcer._stats import ( - _refresh_recently_played_completions, - ) - - result = _refresh_recently_played_completions(games, Config()) - assert result == games - - def test_recently_played_game_is_refreshed(self) -> None: - from steam_backlog_enforcer._stats import _refresh_recently_played_completions - from steam_backlog_enforcer.steam_api import AchievementInfo - - game = GameInfo(1, "G", 5, 0, 60) - new_achievements = [ - AchievementInfo("a1", "A1", achieved=True, unlock_time=1_500_001), - AchievementInfo("a2", "A2", achieved=True, unlock_time=1_500_002), - AchievementInfo("a3", "A3", achieved=False, unlock_time=0), - AchievementInfo("a4", "A4", achieved=False, unlock_time=0), - AchievementInfo("a5", "A5", achieved=False, unlock_time=0), - ] - with ( - patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf, - patch(f"{_PKG}.SteamAPIClient") as mock_cls, - patch(f"{_PKG}._echo"), - ): - mock_sf.stat.return_value.st_mtime = 1_000_000.0 - mock_cls.return_value.get_owned_games.return_value = [ - {"appid": 1, "rtime_last_played": 1_500_000} - ] - mock_cls.return_value.get_achievement_details.return_value = ( - new_achievements - ) - result = _refresh_recently_played_completions([game], Config()) - refreshed = next(g for g in result if g.app_id == 1) - assert refreshed.unlocked_achievements == 2 - - def test_get_achievement_details_empty_keeps_old_game(self) -> None: - from steam_backlog_enforcer._stats import _refresh_recently_played_completions - - game = GameInfo(1, "G", 5, 3, 60) - with ( - patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf, - patch(f"{_PKG}.SteamAPIClient") as mock_cls, - patch(f"{_PKG}._echo"), - ): - mock_sf.stat.return_value.st_mtime = 1_000_000.0 - mock_cls.return_value.get_owned_games.return_value = [ - {"appid": 1, "rtime_last_played": 1_500_000} - ] - mock_cls.return_value.get_achievement_details.return_value = [] - result = _refresh_recently_played_completions([game], Config()) - refreshed = next(g for g in result if g.app_id == 1) - assert refreshed.unlocked_achievements == 3 diff --git a/steam_backlog_enforcer/tests/test_stats_cmd_stats.py b/steam_backlog_enforcer/tests/test_stats_cmd_stats.py new file mode 100644 index 0000000..3833992 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_stats_cmd_stats.py @@ -0,0 +1,224 @@ +"""Tests for _stats module — 100% branch coverage.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer._stats import ( + _GameTimes, + cmd_stats, +) +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.protondb import ProtonDBRating +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._stats" + + +def _game( + app_id: int = 1, + name: str = "G", + hours: float = 10.0, + total: int = 10, + unlocked: int = 0, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=5, + count_comp=20, + ) + + +def _unplayable_rating(app_id: int) -> ProtonDBRating: + return ProtonDBRating(app_id=app_id, tier="borked") + + +class TestCmdStats: + """Tests for cmd_stats.""" + + def _config(self) -> Config: + """Test config.""" + return Config(steam_api_key="k", steam_id="i") + + def _snapshot_game(self, app_id: int = 1, hours: float = 20.0) -> dict[str, object]: + """Test snapshot game.""" + return { + "app_id": app_id, + "name": f"Game{app_id}", + "total_achievements": 10, + "unlocked_achievements": 0, + "playtime_minutes": 60, + "completionist_hours": hours, + "comp_100_count": 5, + "count_comp": 20, + } + + def _run_cmd_stats( + self, + state: State, + hltb_skip: int = 0, + linux_skip: int = 0, + no_data_skip: int = 0, + ) -> list[str]: + """Test run cmd stats.""" + snapshot = [self._snapshot_game()] + game = GameInfo.from_snapshot(snapshot[0]) + entry = _GameTimes( + game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 + ) + echoed: list[str] = [] + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch( + f"{_PKG}._filter_qualifying_games", + return_value=([entry], hltb_skip, linux_skip, no_data_skip), + ), + patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), + patch(f"{_PKG}._print_player_speed_scenario"), + patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + patch(f"{_PKG}._print_pace_scenario"), + patch(f"{_PKG}._print_scenario"), + ): + cmd_stats(self._config(), state) + return echoed + + def test_no_snapshot(self) -> None: + """Test no snapshot.""" + echoed: list[str] = [] + state = State() + with ( + patch(f"{_PKG}.load_snapshot", return_value=None), + patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + cmd_stats(self._config(), state) + assert any("No snapshot found" in s for s in echoed) + + def test_with_no_current_game(self) -> None: + """Test with no current game.""" + state = State() + echoed = self._run_cmd_stats(state) + assert any("Qualifying games" in s for s in echoed) + assert not any("Current game:" in s for s in echoed) + + def test_with_current_game(self) -> None: + """Test with current game.""" + state = State(current_app_id=42, current_game_name="Hollow Knight") + echoed = self._run_cmd_stats(state) + assert any("Current game:" in s and "Hollow Knight" in s for s in echoed) + + def test_hltb_skipped_shown(self) -> None: + """Test hltb skipped shown.""" + state = State() + echoed = self._run_cmd_stats(state, hltb_skip=3) + assert any("HLTB-skipped" in s for s in echoed) + + def test_linux_skipped_shown(self) -> None: + """Test linux skipped shown.""" + state = State() + echoed = self._run_cmd_stats(state, linux_skip=2) + assert any("Linux-skipped" in s for s in echoed) + + def test_no_data_skipped_shown(self) -> None: + """Test no data skipped shown.""" + state = State() + echoed = self._run_cmd_stats(state, no_data_skip=1) + assert any("No-data-skipped" in s for s in echoed) + + def test_zero_skips_not_shown(self) -> None: + """Test zero skips not shown.""" + state = State() + echoed = self._run_cmd_stats(state) + assert not any("HLTB-skipped" in s for s in echoed) + assert not any("Linux-skipped" in s for s in echoed) + assert not any("No-data-skipped" in s for s in echoed) + + def test_finished_games_count_uses_snapshot_complete(self) -> None: + """'Finished games' count uses snapshot is_complete, not finished_app_ids.""" + state = State() + # finished_app_ids has 1 entry, but snapshot has 2 complete games — count = 2. + state.finished_app_ids = [99] + snapshot_complete = { + **self._snapshot_game(app_id=2), + "unlocked_achievements": 10, + } + snapshot = [self._snapshot_game(app_id=1), snapshot_complete] + game = GameInfo.from_snapshot(self._snapshot_game()) + entry = _GameTimes( + game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 + ) + echoed: list[str] = [] + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch( + f"{_PKG}._filter_qualifying_games", + return_value=([entry], 0, 0, 0), + ), + patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), + patch(f"{_PKG}._print_player_speed_scenario"), + patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + patch(f"{_PKG}._print_pace_scenario"), + patch(f"{_PKG}._print_scenario"), + ): + cmd_stats(self._config(), state) + assert any("Finished games" in s and "1" in s for s in echoed) + + def test_detail_data_complete_message_shown(self) -> None: + """'Detail data: ...' shown when all qualifying games have rush hours.""" + state = State() + echoed = self._run_cmd_stats(state) + # entry has rush_hours=15.0 > 0, so missing_rush_final == 0 and total_q == 1 + assert any("Detail data" in s for s in echoed) + + def test_note_missing_rush_shown_when_rush_absent(self) -> None: + """'Note: X games still missing...' shown when rush_hours <= 0 after fetch.""" + state = State() + snapshot = [self._snapshot_game()] + game = GameInfo.from_snapshot(snapshot[0]) + entry = _GameTimes( + game=game, worst_hours=20.0, rush_hours=-1.0, leisure_100h=-1.0 + ) + echoed: list[str] = [] + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch( + f"{_PKG}._filter_qualifying_games", + return_value=([entry], 0, 0, 0), + ), + patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), + patch(f"{_PKG}._ensure_rush_data", return_value=False), + patch(f"{_PKG}._print_player_speed_scenario"), + patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + patch(f"{_PKG}._print_pace_scenario"), + patch(f"{_PKG}._print_scenario"), + patch(f"{_PKG}._print_worst_example"), + ): + cmd_stats(self._config(), state) + assert any("still missing" in s for s in echoed) + + def test_no_detail_message_when_no_qualifying_games(self) -> None: + """Neither 'Note' nor 'Detail data' shown when qualified list is empty.""" + state = State() + snapshot = [self._snapshot_game()] + echoed: list[str] = [] + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch( + f"{_PKG}._filter_qualifying_games", + return_value=([], 0, 0, 0), + ), + patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), + patch(f"{_PKG}._ensure_rush_data", return_value=False), + patch(f"{_PKG}._print_player_speed_scenario"), + patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + patch(f"{_PKG}._print_pace_scenario"), + patch(f"{_PKG}._print_scenario"), + patch(f"{_PKG}._print_worst_example"), + ): + cmd_stats(self._config(), state) + assert not any("Detail data" in s for s in echoed) + assert not any("still missing" in s for s in echoed) diff --git a/steam_backlog_enforcer/tests/test_stats_cmd_stats_refilter_called_when_ensure_rush_data_re.py b/steam_backlog_enforcer/tests/test_stats_cmd_stats_refilter_called_when_ensure_rush_data_re.py new file mode 100644 index 0000000..d7487c7 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_stats_cmd_stats_refilter_called_when_ensure_rush_data_re.py @@ -0,0 +1,223 @@ +"""Tests for _stats module — 100% branch coverage.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import patch + +from steam_backlog_enforcer._stats import ( + _GameTimes, + cmd_stats, +) +from steam_backlog_enforcer._web_dataset import PaceVsHLTB +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.protondb import ProtonDBRating +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._stats" + + +def _game( + app_id: int = 1, + name: str = "G", + hours: float = 10.0, + total: int = 10, + unlocked: int = 0, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=5, + count_comp=20, + ) + + +def _unplayable_rating(app_id: int) -> ProtonDBRating: + return ProtonDBRating(app_id=app_id, tier="borked") + + +class TestCmdStatsGroup2: + """Tests for cmd_stats.""" + + def _config(self) -> Config: + """Test config.""" + return Config(steam_api_key="k", steam_id="i") + + def _snapshot_game(self, app_id: int = 1, hours: float = 20.0) -> dict[str, object]: + """Test snapshot game.""" + return { + "app_id": app_id, + "name": f"Game{app_id}", + "total_achievements": 10, + "unlocked_achievements": 0, + "playtime_minutes": 60, + "completionist_hours": hours, + "comp_100_count": 5, + "count_comp": 20, + } + + def _run_cmd_stats( + self, + state: State, + hltb_skip: int = 0, + linux_skip: int = 0, + no_data_skip: int = 0, + ) -> list[str]: + """Test run cmd stats.""" + snapshot = [self._snapshot_game()] + game = GameInfo.from_snapshot(snapshot[0]) + entry = _GameTimes( + game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 + ) + echoed: list[str] = [] + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch( + f"{_PKG}._filter_qualifying_games", + return_value=([entry], hltb_skip, linux_skip, no_data_skip), + ), + patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), + patch(f"{_PKG}._print_player_speed_scenario"), + patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + patch(f"{_PKG}._print_pace_scenario"), + patch(f"{_PKG}._print_scenario"), + ): + cmd_stats(self._config(), state) + return echoed + + def test_refilter_called_when_ensure_rush_data_returns_true(self) -> None: + """_filter_qualifying_games called twice when _ensure_rush_data returns True.""" + state = State() + snapshot = [self._snapshot_game()] + game = GameInfo.from_snapshot(snapshot[0]) + entry = _GameTimes( + game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 + ) + filter_calls: list[int] = [] + + def count_filter( + _games: object, _state: object + ) -> tuple[list[_GameTimes], int, int, int]: + """Test count filter.""" + filter_calls.append(1) + return [entry], 0, 0, 0 + + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch(f"{_PKG}._filter_qualifying_games", side_effect=count_filter), + patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), + patch(f"{_PKG}._ensure_rush_data", return_value=True), + patch(f"{_PKG}._print_player_speed_scenario"), + patch(f"{_PKG}._echo"), + patch(f"{_PKG}._print_pace_scenario"), + patch(f"{_PKG}._print_scenario"), + patch(f"{_PKG}._print_worst_example"), + ): + cmd_stats(self._config(), state) + assert len(filter_calls) == 2 + + def test_games_done_since_start_passed_to_pace(self) -> None: + """_print_pace_scenario gets only games completed after started_at.""" + + started = datetime(2026, 1, 1, tzinfo=timezone.utc) + state = State(enforcement_started_at=started.isoformat()) + + after_ts = int(datetime(2026, 3, 1, tzinfo=timezone.utc).timestamp()) + before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp()) + + def _ach(ts: int) -> dict[str, object]: + """Test ach.""" + return { + "api_name": "A", + "display_name": "A", + "achieved": True, + "unlock_time": ts, + } + + # app_id=2: completed AFTER enforcement start → should count + snapshot_after = { + **self._snapshot_game(app_id=2), + "unlocked_achievements": 10, + "achievements": [_ach(after_ts)] * 10, + } + # app_id=3: completed BEFORE enforcement start → should NOT count + snapshot_before = { + **self._snapshot_game(app_id=3), + "unlocked_achievements": 10, + "achievements": [_ach(before_ts)] * 10, + } + snapshot = [self._snapshot_game(app_id=1), snapshot_after, snapshot_before] + game = GameInfo.from_snapshot(self._snapshot_game()) + entry = _GameTimes( + game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 + ) + captured: dict[str, int] = {} + + def capture_pace(_state: object, _remaining: object, games_done: int) -> None: + """Test capture pace.""" + captured["games_done"] = games_done + + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch( + f"{_PKG}._filter_qualifying_games", + return_value=([entry], 0, 0, 0), + ), + patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), + patch(f"{_PKG}._print_player_speed_scenario"), + patch(f"{_PKG}._echo"), + patch(f"{_PKG}._print_pace_scenario", side_effect=capture_pace), + patch(f"{_PKG}._print_scenario"), + patch(f"{_PKG}._print_worst_example"), + ): + cmd_stats(self._config(), state) + assert captured["games_done"] == 1 # only the post-start game + + def test_player_speed_scenario_called_with_pace_and_totals(self) -> None: + """_print_player_speed_scenario receives pace, rush_total, and leisure_total.""" + state = State() + snapshot = [self._snapshot_game()] + game = GameInfo.from_snapshot(snapshot[0]) + entry = _GameTimes( + game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 + ) + pace = PaceVsHLTB( + calibration_count=5, + ratio_vs_rush=1.1, + ratio_vs_leisure=0.4, + interpolation_t=0.05, + player_style="rush_to_leisure", + ) + captured: dict[str, object] = {} + + def capture_player_speed(p: object, rush: float, leisure: float) -> None: + """Test capture player speed.""" + captured["pace"] = p + captured["rush"] = rush + captured["leisure"] = leisure + + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch( + f"{_PKG}._filter_qualifying_games", + return_value=([entry], 0, 0, 0), + ), + patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), + patch(f"{_PKG}.compute_pace_vs_hltb", return_value=pace), + patch( + f"{_PKG}._print_player_speed_scenario", + side_effect=capture_player_speed, + ), + patch(f"{_PKG}._echo"), + patch(f"{_PKG}._print_pace_scenario"), + patch(f"{_PKG}._print_scenario"), + patch(f"{_PKG}._print_worst_example"), + ): + cmd_stats(self._config(), state) + assert captured["pace"] is pace + assert captured["rush"] == 15.0 + assert captured["leisure"] == 25.0 diff --git a/steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py b/steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py new file mode 100644 index 0000000..02576d9 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py @@ -0,0 +1,128 @@ +"""Tests for _stats module — 100% branch coverage.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer._stats import ( + _ensure_completed_rush_data, + _ensure_rush_data, + _GameTimes, +) +from steam_backlog_enforcer.protondb import ProtonDBRating +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._stats" + + +def _game( + app_id: int = 1, + name: str = "G", + hours: float = 10.0, + total: int = 10, + unlocked: int = 0, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=5, + count_comp=20, + ) + + +def _unplayable_rating(app_id: int) -> ProtonDBRating: + return ProtonDBRating(app_id=app_id, tier="borked") + + +class TestEnsureRushData: + """Tests for _ensure_rush_data.""" + + def _entry(self, rush: float) -> _GameTimes: + """Test entry.""" + return _GameTimes( + game=_game(), worst_hours=10.0, rush_hours=rush, leisure_100h=5.0 + ) + + def test_empty_qualified_returns_false(self) -> None: + """Test empty qualified returns false.""" + with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch: + result = _ensure_rush_data([]) + assert result is False + mock_fetch.assert_not_called() + + def test_all_have_rush_returns_false(self) -> None: + """Test all have rush returns false.""" + entries = [self._entry(10.0), self._entry(5.0)] + with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch: + result = _ensure_rush_data(entries) + assert result is False + mock_fetch.assert_not_called() + + def test_missing_rush_fetches_and_returns_true(self) -> None: + """Test missing rush fetches and returns true.""" + entries = [self._entry(-1.0)] + with ( + patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch, + patch(f"{_PKG}._echo"), + ): + result = _ensure_rush_data(entries) + assert result is True + mock_fetch.assert_called_once() + + +class TestEnsureCompletedRushData: + """Tests for _ensure_completed_rush_data.""" + + def _complete(self, app_id: int = 1, playtime: int = 600) -> GameInfo: + """Test complete.""" + return GameInfo( + app_id=app_id, + name="Done", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=playtime, + completionist_hours=0.0, + comp_100_count=5, + count_comp=20, + ) + + def test_no_complete_games_returns_false_without_fetch(self) -> None: + """Test no complete games returns false without fetch.""" + incomplete = _game(app_id=1, total=10, unlocked=0) + with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch: + result = _ensure_completed_rush_data([incomplete]) + assert result is False + mock_fetch.assert_not_called() + + def test_complete_game_with_zero_playtime_excluded(self) -> None: + """Games with playtime_minutes=0 are skipped (no calibration value).""" + no_play = self._complete(playtime=0) + with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch: + result = _ensure_completed_rush_data([no_play]) + assert result is False + mock_fetch.assert_not_called() + + def test_complete_game_with_playtime_fetches(self) -> None: + """Test complete game with playtime fetches.""" + game = self._complete() + with ( + patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=1) as mock_fetch, + patch(f"{_PKG}._echo"), + ): + result = _ensure_completed_rush_data([game]) + assert result is True + mock_fetch.assert_called_once_with([(1, "Done")]) + + def test_fetch_returns_zero_means_no_new_data(self) -> None: + """When fetch_hltb_detail_missing returns 0, return False (all cached).""" + game = self._complete() + with ( + patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0), + patch(f"{_PKG}._echo"), + ): + result = _ensure_completed_rush_data([game]) + assert result is False diff --git a/steam_backlog_enforcer/tests/test_stats_print_player_speed_scenario.py b/steam_backlog_enforcer/tests/test_stats_print_player_speed_scenario.py new file mode 100644 index 0000000..a999dc7 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_stats_print_player_speed_scenario.py @@ -0,0 +1,156 @@ +"""Tests for _stats module — 100% branch coverage.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer._stats import ( + _print_player_speed_scenario, +) +from steam_backlog_enforcer._web_dataset import PaceVsHLTB +from steam_backlog_enforcer.protondb import ProtonDBRating +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._stats" + + +def _game( + app_id: int = 1, + name: str = "G", + hours: float = 10.0, + total: int = 10, + unlocked: int = 0, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=5, + count_comp=20, + ) + + +def _unplayable_rating(app_id: int) -> ProtonDBRating: + return ProtonDBRating(app_id=app_id, tier="borked") + + +class TestPrintPlayerSpeedScenario: + """Tests for _print_player_speed_scenario — 100 % branch coverage.""" + + def _echoed( + self, + pace: PaceVsHLTB | None, + rush: float = 100.0, + leisure: float = 200.0, + ) -> list[str]: + """Test echoed.""" + out: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: out.append(a[0])): + _print_player_speed_scenario(pace, rush, leisure) + return out + + def test_none_pace_shows_no_calibration_message(self) -> None: + """Test none pace shows no calibration message.""" + echoed = self._echoed(None) + assert any("No calibration data" in s for s in echoed) + + def test_zero_calibration_count_shows_no_calibration_message(self) -> None: + """Test zero calibration count shows no calibration message.""" + pace = PaceVsHLTB( + calibration_count=0, + ratio_vs_rush=-1.0, + ratio_vs_leisure=-1.0, + interpolation_t=-1.0, + player_style="unknown", + ) + echoed = self._echoed(pace) + assert any("No calibration data" in s for s in echoed) + + def test_ratio_vs_rush_shown_when_positive(self) -> None: + """Test ratio vs rush shown when positive.""" + pace = PaceVsHLTB( + calibration_count=5, + ratio_vs_rush=1.05, + ratio_vs_leisure=-1.0, + interpolation_t=-1.0, + player_style="unknown", + ) + echoed = self._echoed(pace) + assert any("rush pace" in s for s in echoed) + + def test_ratio_vs_leisure_shown_when_positive(self) -> None: + """Test ratio vs leisure shown when positive.""" + pace = PaceVsHLTB( + calibration_count=5, + ratio_vs_rush=1.05, + ratio_vs_leisure=0.5, + interpolation_t=-1.0, + player_style="unknown", + ) + echoed = self._echoed(pace) + assert any("leisure pace" in s for s in echoed) + + def test_interpolation_t_shown_when_not_minus_one(self) -> None: + """Test interpolation t shown when not minus one.""" + pace = PaceVsHLTB( + calibration_count=5, + ratio_vs_rush=1.05, + ratio_vs_leisure=0.5, + interpolation_t=0.1, + player_style="rush_to_leisure", + ) + echoed = self._echoed(pace) + assert any("Interpolation t" in s for s in echoed) + + def test_estimate_uses_interpolation_when_available(self) -> None: + # rush=100, leisure=200, t=0.5 → est=150 + """Test estimate uses interpolation when available.""" + pace = PaceVsHLTB( + calibration_count=5, + ratio_vs_rush=1.5, + ratio_vs_leisure=0.5, + interpolation_t=0.5, + player_style="rush_to_leisure", + ) + echoed = self._echoed(pace, rush=100.0, leisure=200.0) + assert any("150" in s for s in echoed) + + def test_estimate_falls_back_to_ratio_when_no_interpolation(self) -> None: + # interpolation_t=-1, ratio_vs_rush=2.0, rush=100 → est=200 + """Test estimate falls back to ratio when no interpolation.""" + pace = PaceVsHLTB( + calibration_count=5, + ratio_vs_rush=2.0, + ratio_vs_leisure=-1.0, + interpolation_t=-1.0, + player_style="unknown", + ) + echoed = self._echoed(pace, rush=100.0, leisure=0.0) + assert any("200" in s for s in echoed) + + def test_no_estimate_when_both_methods_unavailable(self) -> None: + """No 'Estimated backlog total' line when t=-1 and ratio=-1.""" + pace = PaceVsHLTB( + calibration_count=5, + ratio_vs_rush=-1.0, + ratio_vs_leisure=-1.0, + interpolation_t=-1.0, + player_style="unknown", + ) + echoed = self._echoed(pace, rush=100.0, leisure=0.0) + assert not any("Estimated backlog total" in s for s in echoed) + + def test_no_estimate_when_rush_total_zero_and_no_interpolation(self) -> None: + """No estimate line when rush_total=0 and interpolation_t=-1.""" + pace = PaceVsHLTB( + calibration_count=5, + ratio_vs_rush=1.5, + ratio_vs_leisure=-1.0, + interpolation_t=-1.0, + player_style="unknown", + ) + echoed = self._echoed(pace, rush=0.0, leisure=0.0) + assert not any("Estimated backlog total" in s for s in echoed) diff --git a/steam_backlog_enforcer/tests/test_stats_print_worst_example.py b/steam_backlog_enforcer/tests/test_stats_print_worst_example.py new file mode 100644 index 0000000..fdcea19 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_stats_print_worst_example.py @@ -0,0 +1,162 @@ +"""Tests for _stats module — 100% branch coverage.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer._stats import ( + _GameTimes, + _print_worst_example, +) +from steam_backlog_enforcer.protondb import ProtonDBRating +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._stats" + + +def _game( + app_id: int = 1, + name: str = "G", + hours: float = 10.0, + total: int = 10, + unlocked: int = 0, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=5, + count_comp=20, + ) + + +def _unplayable_rating(app_id: int) -> ProtonDBRating: + return ProtonDBRating(app_id=app_id, tier="borked") + + +class TestPrintWorstExample: + """Tests for _print_worst_example.""" + + def test_empty_list_does_nothing(self) -> None: + """Test empty list does nothing.""" + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_worst_example([]) + assert echoed == [] + + def test_example_with_rush_and_leisure(self) -> None: + """Test example with rush and leisure.""" + entry = _GameTimes( + game=_game(name="Portal"), + worst_hours=15.0, + rush_hours=5.0, + leisure_100h=20.0, + hltb_game_id=99999, + ) + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_worst_example([entry]) + assert any("Portal" in s for s in echoed) + assert any("Rush" in s for s in echoed) + assert any("Leisure" in s for s in echoed) + + def test_example_without_rush(self) -> None: + """Test example without rush.""" + entry = _GameTimes( + game=_game(name="X"), + worst_hours=15.0, + rush_hours=-1.0, + leisure_100h=20.0, + hltb_game_id=99999, + ) + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_worst_example([entry]) + assert not any("Rush" in s for s in echoed) + assert any("Leisure" in s for s in echoed) + + def test_example_without_leisure(self) -> None: + """Test example without leisure.""" + entry = _GameTimes( + game=_game(name="Y"), + worst_hours=15.0, + rush_hours=5.0, + leisure_100h=-1.0, + hltb_game_id=99999, + ) + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_worst_example([entry]) + assert any("Rush" in s for s in echoed) + assert not any("Leisure" in s for s in echoed) + + def test_hltb_search_url_shown_when_lookup_finds_nothing(self) -> None: + """Falls back to search URL when hltb_game_id is 0 and lookup finds nothing.""" + entry = _GameTimes( + game=_game(name="Portal 2"), + worst_hours=15.0, + rush_hours=-1.0, + leisure_100h=-1.0, + ) + echoed: list[str] = [] + with ( + patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0), + patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}), + ): + _print_worst_example([entry]) + assert any("howlongtobeat.com" in s and "Portal+2" in s for s in echoed) + + def test_hltb_direct_link_shown_after_on_demand_lookup(self) -> None: + """Direct link shown when on-demand lookup successfully finds the game ID.""" + entry = _GameTimes( + game=_game(app_id=111, name="Portal 2"), + worst_hours=15.0, + rush_hours=-1.0, + leisure_100h=-1.0, + ) + echoed: list[str] = [] + with ( + patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + patch(f"{_PKG}.fetch_hltb_detail_missing", return_value=0), + patch(f"{_PKG}.load_hltb_game_id_cache", return_value={111: 42000}), + ): + _print_worst_example([entry]) + assert any("howlongtobeat.com/game/42000" in s for s in echoed) + assert not any("?q=" in s for s in echoed) + + def test_hltb_direct_link_shown_when_game_id_known(self) -> None: + """Direct HLTB game link shown when hltb_game_id is populated.""" + entry = _GameTimes( + game=_game(name="Devil May Cry 5"), + worst_hours=186.0, + rush_hours=50.0, + leisure_100h=186.0, + hltb_game_id=57514, + ) + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_worst_example([entry]) + assert any("howlongtobeat.com/game/57514" in s for s in echoed) + assert not any("?q=" in s for s in echoed) + + def test_entries_with_zero_worst_hours_excluded_from_examples(self) -> None: + """Games with worst_hours <= 0 are not selected as the example.""" + bad = _GameTimes( + game=_game(name="Skip"), worst_hours=0.0, rush_hours=-1.0, leisure_100h=-1.0 + ) + good = _GameTimes( + game=_game(name="Pick"), + worst_hours=10.0, + rush_hours=-1.0, + leisure_100h=-1.0, + hltb_game_id=99999, + ) + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_worst_example([bad, good]) + assert any("Pick" in s for s in echoed) + assert not any("Skip" in s for s in echoed) diff --git a/steam_backlog_enforcer/tests/test_stats_refresh_recently_played_completions.py b/steam_backlog_enforcer/tests/test_stats_refresh_recently_played_completions.py new file mode 100644 index 0000000..f945a1b --- /dev/null +++ b/steam_backlog_enforcer/tests/test_stats_refresh_recently_played_completions.py @@ -0,0 +1,134 @@ +"""Tests for _stats module — 100% branch coverage.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer.config import Config +from steam_backlog_enforcer.protondb import ProtonDBRating +from steam_backlog_enforcer.steam_api import GameInfo, SteamAPIError + +_PKG = "steam_backlog_enforcer._stats" + + +def _game( + app_id: int = 1, + name: str = "G", + hours: float = 10.0, + total: int = 10, + unlocked: int = 0, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=5, + count_comp=20, + ) + + +def _unplayable_rating(app_id: int) -> ProtonDBRating: + return ProtonDBRating(app_id=app_id, tier="borked") + + +class TestRefreshRecentlyPlayedCompletions: + """Tests for _refresh_recently_played_completions.""" + + def test_oserror_on_stat_returns_games_unchanged(self) -> None: + """Test oserror on stat returns games unchanged.""" + games = [GameInfo(1, "G", 10, 0, 60)] + with patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf: + mock_sf.stat.side_effect = OSError("no file") + from steam_backlog_enforcer._stats import ( + _refresh_recently_played_completions, + ) + + result = _refresh_recently_played_completions(games, Config()) + assert result == games + + def test_steam_api_error_returns_games_unchanged(self) -> None: + """A SteamAPIError while fetching owned games is swallowed.""" + games = [GameInfo(1, "G", 10, 0, 60)] + with ( + patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf, + patch(f"{_PKG}.SteamAPIClient") as mock_cls, + ): + mock_sf.stat.return_value.st_mtime = 1_000_000.0 + mock_cls.return_value.get_owned_games.side_effect = SteamAPIError( + "api down" + ) + from steam_backlog_enforcer._stats import ( + _refresh_recently_played_completions, + ) + + result = _refresh_recently_played_completions(games, Config()) + assert result == games + + def test_no_recently_played_returns_games_unchanged(self) -> None: + """Test no recently played returns games unchanged.""" + games = [GameInfo(1, "G", 10, 0, 60)] + with ( + patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf, + patch(f"{_PKG}.SteamAPIClient") as mock_cls, + ): + mock_sf.stat.return_value.st_mtime = 1_000_000.0 + mock_cls.return_value.get_owned_games.return_value = [ + {"appid": 1, "rtime_last_played": 500_000} + ] + from steam_backlog_enforcer._stats import ( + _refresh_recently_played_completions, + ) + + result = _refresh_recently_played_completions(games, Config()) + assert result == games + + def test_recently_played_game_is_refreshed(self) -> None: + """Test recently played game is refreshed.""" + from steam_backlog_enforcer._stats import _refresh_recently_played_completions + from steam_backlog_enforcer.steam_api import AchievementInfo + + game = GameInfo(1, "G", 5, 0, 60) + new_achievements = [ + AchievementInfo("a1", "A1", achieved=True, unlock_time=1_500_001), + AchievementInfo("a2", "A2", achieved=True, unlock_time=1_500_002), + AchievementInfo("a3", "A3", achieved=False, unlock_time=0), + AchievementInfo("a4", "A4", achieved=False, unlock_time=0), + AchievementInfo("a5", "A5", achieved=False, unlock_time=0), + ] + with ( + patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf, + patch(f"{_PKG}.SteamAPIClient") as mock_cls, + patch(f"{_PKG}._echo"), + ): + mock_sf.stat.return_value.st_mtime = 1_000_000.0 + mock_cls.return_value.get_owned_games.return_value = [ + {"appid": 1, "rtime_last_played": 1_500_000} + ] + mock_cls.return_value.get_achievement_details.return_value = ( + new_achievements + ) + result = _refresh_recently_played_completions([game], Config()) + refreshed = next(g for g in result if g.app_id == 1) + assert refreshed.unlocked_achievements == 2 + + def test_get_achievement_details_empty_keeps_old_game(self) -> None: + """Test get achievement details empty keeps old game.""" + from steam_backlog_enforcer._stats import _refresh_recently_played_completions + + game = GameInfo(1, "G", 5, 3, 60) + with ( + patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf, + patch(f"{_PKG}.SteamAPIClient") as mock_cls, + patch(f"{_PKG}._echo"), + ): + mock_sf.stat.return_value.st_mtime = 1_000_000.0 + mock_cls.return_value.get_owned_games.return_value = [ + {"appid": 1, "rtime_last_played": 1_500_000} + ] + mock_cls.return_value.get_achievement_details.return_value = [] + result = _refresh_recently_played_completions([game], Config()) + refreshed = next(g for g in result if g.app_id == 1) + assert refreshed.unlocked_achievements == 3 diff --git a/steam_backlog_enforcer/tests/test_stats_sum_hours.py b/steam_backlog_enforcer/tests/test_stats_sum_hours.py new file mode 100644 index 0000000..e53e69f --- /dev/null +++ b/steam_backlog_enforcer/tests/test_stats_sum_hours.py @@ -0,0 +1,177 @@ +"""Tests for _stats module — 100% branch coverage.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +from steam_backlog_enforcer._stats import ( + _format_completion_date, + _GameTimes, + _print_pace_scenario, + _print_scenario, + _sum_hours, +) +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.protondb import ProtonDBRating +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._stats" + + +def _game( + app_id: int = 1, + name: str = "G", + hours: float = 10.0, + total: int = 10, + unlocked: int = 0, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=5, + count_comp=20, + ) + + +def _unplayable_rating(app_id: int) -> ProtonDBRating: + return ProtonDBRating(app_id=app_id, tier="borked") + + +class TestSumHours: + """Tests for _sum_hours.""" + + def _make_entry(self, worst: float, rush: float, leisure: float) -> _GameTimes: + """Test make entry.""" + return _GameTimes( + game=_game(), worst_hours=worst, rush_hours=rush, leisure_100h=leisure + ) + + def test_empty_list(self) -> None: + """Test empty list.""" + total, missing = _sum_hours([], "worst_hours") + assert total == 0.0 + assert missing == 0 + + def test_all_positive(self) -> None: + """Test all positive.""" + entries = [ + self._make_entry(10.0, 8.0, 12.0), + self._make_entry(20.0, 15.0, 25.0), + ] + total, missing = _sum_hours(entries, "worst_hours") + assert total == 30.0 + assert missing == 0 + + def test_some_negative(self) -> None: + """Test some negative.""" + entries = [ + self._make_entry(10.0, -1.0, 12.0), + self._make_entry(-1.0, 8.0, 25.0), + ] + total, missing = _sum_hours(entries, "worst_hours") + assert total == 10.0 + assert missing == 1 + + def test_all_negative(self) -> None: + """Test all negative.""" + entries = [self._make_entry(-1.0, -1.0, -1.0)] + total, missing = _sum_hours(entries, "rush_hours") + assert total == 0.0 + assert missing == 1 + + +class TestFormatCompletionDate: + """Tests for _format_completion_date.""" + + def test_zero_hours_returns_na(self) -> None: + """Test zero hours returns na.""" + assert _format_completion_date(0.0, 4.0) == "N/A" + + def test_negative_hours_returns_na(self) -> None: + """Test negative hours returns na.""" + assert _format_completion_date(-5.0, 4.0) == "N/A" + + def test_zero_daily_hours_returns_na(self) -> None: + """Test zero daily hours returns na.""" + assert _format_completion_date(100.0, 0.0) == "N/A" + + def test_negative_daily_hours_returns_na(self) -> None: + """Test negative daily hours returns na.""" + assert _format_completion_date(100.0, -1.0) == "N/A" + + def test_normal_returns_days_and_date(self) -> None: + """Test normal returns days and date.""" + result = _format_completion_date(40.0, 4.0) + # 40 / 4 = 10 days + assert result.startswith("10 days (") + assert ")" in result + + +class TestPrintScenario: + """Tests for _print_scenario.""" + + def test_no_data_prints_no_data_message(self) -> None: + """Test no data prints no data message.""" + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_scenario("2. RUSH", 0.0, 0, 5) + assert any("No data available" in s for s in echoed) + + def test_with_data_no_missing(self) -> None: + """Test with data no missing.""" + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_scenario("2. RUSH", 100.0, 0, 5) + assert any("Total:" in s for s in echoed) + assert not any("had no data" in s for s in echoed) + + def test_with_data_and_missing(self) -> None: + """Test with data and missing.""" + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_scenario("2. RUSH", 100.0, 2, 5) + assert any("had no data" in s for s in echoed) + + +class TestPrintPaceScenario: + """Tests for _print_pace_scenario.""" + + def test_no_start_date(self) -> None: + """Test no start date.""" + state = State() + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_pace_scenario(state, 10, 0) + assert any("No start date recorded" in s for s in echoed) + + def test_invalid_start_date(self) -> None: + """Test invalid start date.""" + state = State(enforcement_started_at="not-a-date") + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_pace_scenario(state, 10, 0) + assert any("Invalid enforcement_started_at" in s for s in echoed) + + def test_no_games_finished(self) -> None: + """Test no games finished.""" + started = datetime.now(timezone.utc) - timedelta(days=30) + state = State(enforcement_started_at=started.isoformat()) + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_pace_scenario(state, 10, 0) + assert any("No games finished yet" in s for s in echoed) + + def test_normal_pace(self) -> None: + """Test normal pace.""" + started = datetime.now(timezone.utc) - timedelta(days=60) + state = State(enforcement_started_at=started.isoformat()) + echoed: list[str] = [] + with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): + _print_pace_scenario(state, 5, 3) + assert any("Pace:" in s for s in echoed) + assert any("Est. complete:" in s for s in echoed) diff --git a/steam_backlog_enforcer/tests/test_stats_unplayable_rating_counts_linux_skipped.py b/steam_backlog_enforcer/tests/test_stats_unplayable_rating_counts_linux_skipped.py new file mode 100644 index 0000000..3efa6c4 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_stats_unplayable_rating_counts_linux_skipped.py @@ -0,0 +1,166 @@ +"""Tests for _stats module — 100% branch coverage.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer._stats import ( + _filter_qualifying_games, + _GameTimes, +) +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.protondb import ProtonDBRating +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._stats" + + +def _game( + app_id: int = 1, + name: str = "G", + hours: float = 10.0, + total: int = 10, + unlocked: int = 0, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + comp_100_count=5, + count_comp=20, + ) + + +def _unplayable_rating(app_id: int) -> ProtonDBRating: + return ProtonDBRating(app_id=app_id, tier="borked") + + +class TestFilterQualifyingGamesGroup2: + """Tests for _filter_qualifying_games.""" + + def _run( + self, + games: list[GameInfo], + state: State, + rush_cache: dict[int, float] | None = None, + leisure_cache: dict[int, float] | None = None, + game_id_cache: dict[int, int] | None = None, + ) -> tuple[list[_GameTimes], int, int, int]: + """Test run.""" + with ( + patch(f"{_PKG}.load_hltb_rush_cache", return_value=rush_cache or {}), + patch( + f"{_PKG}.load_hltb_leisure_100h_cache", + return_value=leisure_cache or {}, + ), + patch( + f"{_PKG}.load_hltb_game_id_cache", + return_value=game_id_cache or {}, + ), + patch(f"{_PKG}._apply_cached_confidence_to_candidates"), + patch(f"{_PKG}._refresh_candidate_confidence_batch"), + patch(f"{_PKG}._confidence_fail_reasons", return_value=[]), + patch(f"{_PKG}.fetch_protondb_ratings", return_value={}), + ): + return _filter_qualifying_games(games, state) + + def test_unplayable_rating_counts_linux_skipped(self) -> None: + """Test unplayable rating counts linux skipped.""" + state = State() + g = _game(app_id=1) + ratings = {1: _unplayable_rating(1)} + with ( + patch(f"{_PKG}.load_hltb_rush_cache", return_value={}), + patch(f"{_PKG}.load_hltb_leisure_100h_cache", return_value={}), + patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}), + patch(f"{_PKG}._apply_cached_confidence_to_candidates"), + patch(f"{_PKG}._refresh_candidate_confidence_batch"), + patch(f"{_PKG}._confidence_fail_reasons", return_value=[]), + patch(f"{_PKG}.fetch_protondb_ratings", return_value=ratings), + ): + qualified, _, linux_skip, _ = _filter_qualifying_games([g], state) + assert linux_skip == 1 + assert len(qualified) == 0 + + def test_no_data_counts_no_data_skipped(self) -> None: + """Game with all -1 hours is counted as no_data_skipped.""" + state = State() + g = _game(app_id=1, hours=-1.0) + qualified, _, _, no_data_skip = self._run([g], state) + assert no_data_skip == 1 + assert len(qualified) == 0 + + def test_worst_hours_positive_when_completionist_hours_positive(self) -> None: + """Test worst hours positive when completionist hours positive.""" + state = State() + g = _game(app_id=1, hours=25.0) + qualified, _, _, _ = self._run([g], state, rush_cache={1: 10.0}) + assert qualified[0].worst_hours == 25.0 + + def test_worst_hours_from_leisure_when_completionist_zero(self) -> None: + """worst_hours falls back to leisure_100h when completionist_hours is zero.""" + state = State() + g = _game(app_id=1, hours=0.0) + qualified, _, _, _ = self._run( + [g], state, rush_cache={1: 5.0}, leisure_cache={1: 6.0} + ) + assert qualified[0].worst_hours == 6.0 + + def test_worst_hours_is_max_when_leisure_exceeds_completionist(self) -> None: + """worst_hours is max(completionist, leisure_100h) when leisure is higher.""" + state = State() + g = _game(app_id=1, hours=25.0) + qualified, _, _, _ = self._run( + [g], state, rush_cache={1: 10.0}, leisure_cache={1: 40.0} + ) + assert qualified[0].worst_hours == 40.0 + + def test_worst_hours_negative_when_all_zero(self) -> None: + """worst_hours = -1 when both completionist_hours and leisure_100h are zero.""" + state = State() + g = _game(app_id=1, hours=0.0) + qualified, _, _, _ = self._run([g], state, rush_cache={1: 5.0}) + assert qualified[0].worst_hours == -1 + + def test_rush_and_leisure_from_cache(self) -> None: + """Test rush and leisure from cache.""" + state = State() + g = _game(app_id=1, hours=30.0) + qualified, _, _, _ = self._run( + [g], state, rush_cache={1: 12.0}, leisure_cache={1: 40.0} + ) + assert qualified[0].rush_hours == 12.0 + assert qualified[0].leisure_100h == 40.0 + + def test_missing_cache_entry_defaults_to_minus_one(self) -> None: + """Test missing cache entry defaults to minus one.""" + state = State() + g = _game(app_id=1, hours=20.0) + qualified, _, _, _ = self._run([g], state) + assert qualified[0].rush_hours == -1 + assert qualified[0].leisure_100h == -1 + + def test_only_rush_nonzero_qualifies(self) -> None: + """Game qualifies if only rush_hours is positive (worst <= 0, leisure <= 0).""" + state = State() + g = _game(app_id=1, hours=-1.0) + qualified, _, _, no_data_skip = self._run([g], state, rush_cache={1: 8.0}) + assert no_data_skip == 0 + assert len(qualified) == 1 + + def test_game_id_populated_from_cache(self) -> None: + """hltb_game_id is taken from game_id_cache.""" + state = State() + g = _game(app_id=1, hours=20.0) + qualified, _, _, _ = self._run([g], state, game_id_cache={1: 57514}) + assert qualified[0].hltb_game_id == 57514 + + def test_game_id_defaults_to_zero_when_not_in_cache(self) -> None: + """hltb_game_id defaults to 0 when not in cache.""" + state = State() + g = _game(app_id=1, hours=20.0) + qualified, _, _, _ = self._run([g], state) + assert qualified[0].hltb_game_id == 0 diff --git a/steam_backlog_enforcer/tests/test_steam_api.py b/steam_backlog_enforcer/tests/test_steam_api.py index 25c1e60..44c5e56 100644 --- a/steam_backlog_enforcer/tests/test_steam_api.py +++ b/steam_backlog_enforcer/tests/test_steam_api.py @@ -3,16 +3,10 @@ from __future__ import annotations from typing import Any -from unittest.mock import MagicMock, patch - -import pytest -import requests from steam_backlog_enforcer.steam_api import ( AchievementInfo, GameInfo, - SteamAPIClient, - SteamAPIError, ) @@ -20,6 +14,7 @@ class TestAchievementInfo: """Tests for AchievementInfo.""" def test_create(self) -> None: + """Test create.""" a = AchievementInfo( api_name="ACH_1", display_name="First", achieved=True, unlock_time=1000 ) @@ -31,6 +26,7 @@ class TestGameInfo: """Tests for GameInfo.""" def test_completion_pct_zero_achievements(self) -> None: + """Test completion pct zero achievements.""" g = GameInfo( app_id=1, name="G", @@ -41,6 +37,7 @@ class TestGameInfo: assert g.completion_pct == 100.0 def test_completion_pct_partial(self) -> None: + """Test completion pct partial.""" g = GameInfo( app_id=1, name="G", @@ -51,6 +48,7 @@ class TestGameInfo: assert g.completion_pct == 50.0 def test_is_complete_true(self) -> None: + """Test is complete true.""" g = GameInfo( app_id=1, name="G", @@ -61,6 +59,7 @@ class TestGameInfo: assert g.is_complete is True def test_is_complete_false(self) -> None: + """Test is complete false.""" g = GameInfo( app_id=1, name="G", @@ -71,6 +70,7 @@ class TestGameInfo: assert g.is_complete is False def test_is_complete_zero(self) -> None: + """Test is complete zero.""" g = GameInfo( app_id=1, name="G", @@ -81,6 +81,7 @@ class TestGameInfo: assert g.is_complete is False def test_to_snapshot(self) -> None: + """Test to snapshot.""" ach = AchievementInfo( api_name="A1", display_name="Ach1", achieved=True, unlock_time=99 ) @@ -99,6 +100,7 @@ class TestGameInfo: assert snap["completionist_hours"] == 5.0 def test_from_snapshot(self) -> None: + """Test from snapshot.""" data: dict[str, Any] = { "app_id": 2, "name": "G2", @@ -121,6 +123,7 @@ class TestGameInfo: assert len(g.achievements) == 1 def test_from_snapshot_defaults(self) -> None: + """Test from snapshot defaults.""" data: dict[str, Any] = { "app_id": 3, "name": "G3", @@ -133,6 +136,7 @@ class TestGameInfo: assert g.achievements == [] def test_from_snapshot_achievement_defaults(self) -> None: + """Test from snapshot achievement defaults.""" data: dict[str, Any] = { "app_id": 4, "name": "G4", @@ -143,191 +147,3 @@ class TestGameInfo: g = GameInfo.from_snapshot(data) assert g.achievements[0].display_name == "X" assert g.achievements[0].unlock_time == 0 - - -class TestSteamAPIClient: - """Tests for SteamAPIClient.""" - - def test_init(self) -> None: - client = SteamAPIClient("key", "id") - assert client.api_key == "key" - assert client.steam_id == "id" - - def test_rate_limit(self) -> None: - client = SteamAPIClient("key", "id") - # Should not block on first call - client._rate_limit() - - def test_rate_limit_throttle(self) -> None: - client = SteamAPIClient("key", "id") - # Fill up the rate limit window - client._request_times = [__import__("time").time()] * client._max_rps - with patch( - "steam_backlog_enforcer.steam_api.time.sleep", - ) as mock_sleep: - # Next call should trigger sleep then succeed - client._rate_limit() - mock_sleep.assert_called() - - def test_get_success(self) -> None: - client = SteamAPIClient("key", "id") - mock_resp = MagicMock() - mock_resp.json.return_value = {"data": "value"} - client.session.get = MagicMock(return_value=mock_resp) - result = client._get("https://example.com/api") - assert result == {"data": "value"} - - def test_get_with_params(self) -> None: - client = SteamAPIClient("key", "id") - mock_resp = MagicMock() - mock_resp.json.return_value = {"data": "value"} - client.session.get = MagicMock(return_value=mock_resp) - result = client._get("https://example.com/api", params={"foo": "bar"}) - assert result == {"data": "value"} - # Verify key was added to existing params dict - call_kwargs = client.session.get.call_args - assert call_kwargs[1]["params"]["foo"] == "bar" - assert call_kwargs[1]["params"]["key"] == "key" - - def test_get_failure(self) -> None: - client = SteamAPIClient("key", "id") - client.session.get = MagicMock(side_effect=requests.RequestException("fail")) - with pytest.raises(SteamAPIError): - client._get("https://example.com/api") - - def test_get_owned_games(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object( - client, - "_get", - return_value={"response": {"games": [{"appid": 440}]}}, - ): - games = client.get_owned_games() - assert len(games) == 1 - assert games[0]["appid"] == 440 - - def test_get_owned_games_empty(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object(client, "_get", return_value={"response": {}}): - games = client.get_owned_games() - assert games == [] - - def test_get_achievement_details(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object( - client, - "_get", - return_value={ - "playerstats": { - "success": True, - "achievements": [ - { - "apiname": "ACH_1", - "name": "First", - "achieved": 1, - "unlocktime": 1000, - }, - ], - }, - }, - ): - result = client.get_achievement_details(440) - assert len(result) == 1 - assert result[0].achieved is True - - def test_get_achievement_details_failure(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object(client, "_get", side_effect=SteamAPIError("fail")): - result = client.get_achievement_details(440) - assert result == [] - - def test_get_achievement_details_not_success(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object( - client, - "_get", - return_value={"playerstats": {"success": False}}, - ): - result = client.get_achievement_details(440) - assert result == [] - - def test_fetch_one_game(self) -> None: - client = SteamAPIClient("key", "id") - ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) - with patch.object(client, "get_achievement_details", return_value=[ach]): - result = client._fetch_one_game( - {"appid": 440, "name": "TF2", "playtime_forever": 60}, - ) - assert result is not None - assert result.app_id == 440 - - def test_fetch_one_game_no_achievements(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object(client, "get_achievement_details", return_value=[]): - result = client._fetch_one_game({"appid": 440}) - assert result is None - - def test_build_game_list(self) -> None: - client = SteamAPIClient("key", "id") - ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) - with ( - patch.object( - client, - "get_owned_games", - return_value=[{"appid": 440, "name": "TF2", "playtime_forever": 60}], - ), - patch.object(client, "get_achievement_details", return_value=[ach]), - ): - progress_calls: list[tuple[int, int]] = [] - - def progress(c: int, t: int) -> None: - progress_calls.append((c, t)) - - games = client.build_game_list(progress_callback=progress) - assert len(games) == 1 - assert len(progress_calls) > 0 - - def test_build_game_list_no_achievements_excluded(self) -> None: - """Games without achievements are excluded from results.""" - client = SteamAPIClient("key", "id") - with ( - patch.object( - client, - "get_owned_games", - return_value=[{"appid": 440, "name": "TF2"}], - ), - patch.object(client, "get_achievement_details", return_value=[]), - ): - games = client.build_game_list() - assert games == [] - - def test_build_game_list_exception_in_future(self) -> None: - client = SteamAPIClient("key", "id") - with ( - patch.object( - client, - "get_owned_games", - return_value=[{"appid": 440, "name": "TF2"}], - ), - patch.object( - client, - "get_achievement_details", - side_effect=SteamAPIError("err"), - ), - ): - games = client.build_game_list() - assert games == [] - - def test_refresh_single_game(self) -> None: - client = SteamAPIClient("key", "id") - ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) - with patch.object(client, "get_achievement_details", return_value=[ach]): - result = client.refresh_single_game(440, "TF2", 60) - assert result is not None - assert result.unlocked_achievements == 1 - - def test_refresh_single_game_no_achievements(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object(client, "get_achievement_details", return_value=[]): - result = client.refresh_single_game(440, "TF2") - assert result is None diff --git a/steam_backlog_enforcer/tests/test_steam_api_steam_a_p_i_client.py b/steam_backlog_enforcer/tests/test_steam_api_steam_a_p_i_client.py new file mode 100644 index 0000000..f616c61 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_steam_api_steam_a_p_i_client.py @@ -0,0 +1,220 @@ +"""Tests for steam_api module.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from steam_backlog_enforcer.steam_api import ( + AchievementInfo, + SteamAPIClient, + SteamAPIError, +) + + +class TestSteamAPIClient: + """Tests for SteamAPIClient.""" + + def test_init(self) -> None: + """Test init.""" + client = SteamAPIClient("key", "id") + assert client.api_key == "key" + assert client.steam_id == "id" + + def test_rate_limit(self) -> None: + """Test rate limit.""" + client = SteamAPIClient("key", "id") + # Should not block on first call + client._rate_limit() + + def test_rate_limit_throttle(self) -> None: + """Test rate limit throttle.""" + client = SteamAPIClient("key", "id") + # Fill up the rate limit window + client._request_times = [__import__("time").time()] * client._max_rps + with patch( + "steam_backlog_enforcer.steam_api.time.sleep", + ) as mock_sleep: + # Next call should trigger sleep then succeed + client._rate_limit() + mock_sleep.assert_called() + + def test_get_success(self) -> None: + """Test get success.""" + client = SteamAPIClient("key", "id") + mock_resp = MagicMock() + mock_resp.json.return_value = {"data": "value"} + client.session.get = MagicMock(return_value=mock_resp) + result = client._get("https://example.com/api") + assert result == {"data": "value"} + + def test_get_with_params(self) -> None: + """Test get with params.""" + client = SteamAPIClient("key", "id") + mock_resp = MagicMock() + mock_resp.json.return_value = {"data": "value"} + client.session.get = MagicMock(return_value=mock_resp) + result = client._get("https://example.com/api", params={"foo": "bar"}) + assert result == {"data": "value"} + # Verify key was added to existing params dict + call_kwargs = client.session.get.call_args + assert call_kwargs[1]["params"]["foo"] == "bar" + assert call_kwargs[1]["params"]["key"] == "key" + + def test_get_failure(self) -> None: + """Test get failure.""" + client = SteamAPIClient("key", "id") + client.session.get = MagicMock(side_effect=requests.RequestException("fail")) + with pytest.raises(SteamAPIError): + client._get("https://example.com/api") + + def test_get_owned_games(self) -> None: + """Test get owned games.""" + client = SteamAPIClient("key", "id") + with patch.object( + client, + "_get", + return_value={"response": {"games": [{"appid": 440}]}}, + ): + games = client.get_owned_games() + assert len(games) == 1 + assert games[0]["appid"] == 440 + + def test_get_owned_games_empty(self) -> None: + """Test get owned games empty.""" + client = SteamAPIClient("key", "id") + with patch.object(client, "_get", return_value={"response": {}}): + games = client.get_owned_games() + assert games == [] + + def test_get_achievement_details(self) -> None: + """Test get achievement details.""" + client = SteamAPIClient("key", "id") + with patch.object( + client, + "_get", + return_value={ + "playerstats": { + "success": True, + "achievements": [ + { + "apiname": "ACH_1", + "name": "First", + "achieved": 1, + "unlocktime": 1000, + }, + ], + }, + }, + ): + result = client.get_achievement_details(440) + assert len(result) == 1 + assert result[0].achieved is True + + def test_get_achievement_details_failure(self) -> None: + """Test get achievement details failure.""" + client = SteamAPIClient("key", "id") + with patch.object(client, "_get", side_effect=SteamAPIError("fail")): + result = client.get_achievement_details(440) + assert result == [] + + def test_get_achievement_details_not_success(self) -> None: + """Test get achievement details not success.""" + client = SteamAPIClient("key", "id") + with patch.object( + client, + "_get", + return_value={"playerstats": {"success": False}}, + ): + result = client.get_achievement_details(440) + assert result == [] + + def test_fetch_one_game(self) -> None: + """Test fetch one game.""" + client = SteamAPIClient("key", "id") + ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) + with patch.object(client, "get_achievement_details", return_value=[ach]): + result = client._fetch_one_game( + {"appid": 440, "name": "TF2", "playtime_forever": 60}, + ) + assert result is not None + assert result.app_id == 440 + + def test_fetch_one_game_no_achievements(self) -> None: + """Test fetch one game no achievements.""" + client = SteamAPIClient("key", "id") + with patch.object(client, "get_achievement_details", return_value=[]): + result = client._fetch_one_game({"appid": 440}) + assert result is None + + def test_build_game_list(self) -> None: + """Test build game list.""" + client = SteamAPIClient("key", "id") + ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) + with ( + patch.object( + client, + "get_owned_games", + return_value=[{"appid": 440, "name": "TF2", "playtime_forever": 60}], + ), + patch.object(client, "get_achievement_details", return_value=[ach]), + ): + progress_calls: list[tuple[int, int]] = [] + + def progress(c: int, t: int) -> None: + """Test progress.""" + progress_calls.append((c, t)) + + games = client.build_game_list(progress_callback=progress) + assert len(games) == 1 + assert len(progress_calls) > 0 + + def test_build_game_list_no_achievements_excluded(self) -> None: + """Games without achievements are excluded from results.""" + client = SteamAPIClient("key", "id") + with ( + patch.object( + client, + "get_owned_games", + return_value=[{"appid": 440, "name": "TF2"}], + ), + patch.object(client, "get_achievement_details", return_value=[]), + ): + games = client.build_game_list() + assert games == [] + + def test_build_game_list_exception_in_future(self) -> None: + """Test build game list exception in future.""" + client = SteamAPIClient("key", "id") + with ( + patch.object( + client, + "get_owned_games", + return_value=[{"appid": 440, "name": "TF2"}], + ), + patch.object( + client, + "get_achievement_details", + side_effect=SteamAPIError("err"), + ), + ): + games = client.build_game_list() + assert games == [] + + def test_refresh_single_game(self) -> None: + """Test refresh single game.""" + client = SteamAPIClient("key", "id") + ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) + with patch.object(client, "get_achievement_details", return_value=[ach]): + result = client.refresh_single_game(440, "TF2", 60) + assert result is not None + assert result.unlocked_achievements == 1 + + def test_refresh_single_game_no_achievements(self) -> None: + """Test refresh single game no achievements.""" + client = SteamAPIClient("key", "id") + with patch.object(client, "get_achievement_details", return_value=[]): + result = client.refresh_single_game(440, "TF2") + assert result is None diff --git a/steam_backlog_enforcer/tests/test_store_blocker.py b/steam_backlog_enforcer/tests/test_store_blocker.py index 4cac8fd..b8d0422 100644 --- a/steam_backlog_enforcer/tests/test_store_blocker.py +++ b/steam_backlog_enforcer/tests/test_store_blocker.py @@ -3,16 +3,11 @@ from __future__ import annotations from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch +from unittest.mock import patch from steam_backlog_enforcer.store_blocker import ( - _block_store_iptables, - _block_via_hosts_install, - _is_iptables_blocked, - _unblock_store_iptables, block_store, is_store_blocked, - unblock_store, ) if TYPE_CHECKING: @@ -23,6 +18,7 @@ class TestIsStoreBlocked: """Tests for is_store_blocked.""" def test_blocked_in_hosts(self, tmp_path: Path) -> None: + """Test blocked in hosts.""" hosts_file = tmp_path / "hosts" hosts_file.write_text("0.0.0.0 store.steampowered.com\n", encoding="utf-8") with ( @@ -34,6 +30,7 @@ class TestIsStoreBlocked: assert is_store_blocked() is True def test_commented_in_hosts(self, tmp_path: Path) -> None: + """Test commented in hosts.""" hosts_file = tmp_path / "hosts" hosts_file.write_text("# 0.0.0.0 store.steampowered.com\n", encoding="utf-8") with ( @@ -49,6 +46,7 @@ class TestIsStoreBlocked: assert is_store_blocked() is False def test_not_in_hosts_iptables_blocked(self, tmp_path: Path) -> None: + """Test not in hosts iptables blocked.""" hosts_file = tmp_path / "hosts" hosts_file.write_text("127.0.0.1 localhost\n", encoding="utf-8") with ( @@ -64,6 +62,7 @@ class TestIsStoreBlocked: assert is_store_blocked() is True def test_hosts_read_error(self, tmp_path: Path) -> None: + """Test hosts read error.""" hosts_file = tmp_path / "nonexistent" with ( patch( @@ -78,6 +77,7 @@ class TestIsStoreBlocked: assert is_store_blocked() is False def test_wrong_redirect_ip(self, tmp_path: Path) -> None: + """Test wrong redirect ip.""" hosts_file = tmp_path / "hosts" hosts_file.write_text("127.0.0.1 store.steampowered.com\n", encoding="utf-8") with ( @@ -97,6 +97,7 @@ class TestBlockStore: """Tests for block_store.""" def test_already_blocked(self) -> None: + """Test already blocked.""" with patch( "steam_backlog_enforcer.store_blocker.is_store_blocked", return_value=True, @@ -104,6 +105,7 @@ class TestBlockStore: assert block_store() is True def test_reblock_succeeds(self) -> None: + """Test reblock succeeds.""" with ( patch( "steam_backlog_enforcer.store_blocker.is_store_blocked", @@ -123,6 +125,7 @@ class TestBlockStore: assert block_store() is True def test_fallback_to_install_script(self) -> None: + """Test fallback to install script.""" with ( patch( "steam_backlog_enforcer.store_blocker.is_store_blocked", @@ -147,6 +150,7 @@ class TestBlockStore: assert block_store() is True def test_all_fail(self) -> None: + """Test all fail.""" with ( patch( "steam_backlog_enforcer.store_blocker.is_store_blocked", @@ -168,6 +172,7 @@ class TestBlockStore: assert block_store() is False def test_iptables_only_succeeds(self) -> None: + """Test iptables only succeeds.""" with ( patch( "steam_backlog_enforcer.store_blocker.is_store_blocked", @@ -190,281 +195,3 @@ class TestBlockStore: ), ): assert block_store() is True - - -class TestBlockViaHostsInstall: - """Tests for _block_via_hosts_install.""" - - def test_already_blocked(self) -> None: - with patch( - "steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=True, - ): - assert _block_via_hosts_install() is True - - def test_script_missing(self, tmp_path: Path) -> None: - with ( - patch( - "steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=False, - ), - patch( - "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", - tmp_path / "nonexistent.sh", - ), - ): - assert _block_via_hosts_install() is False - - def test_script_succeeds(self, tmp_path: Path) -> None: - script = tmp_path / "install.sh" - script.touch() - mock_result = MagicMock(returncode=0) - with ( - patch( - "steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=False, - ), - patch( - "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", - script, - ), - patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ), - ): - assert _block_via_hosts_install() is True - - def test_script_fails(self, tmp_path: Path) -> None: - script = tmp_path / "install.sh" - script.touch() - mock_result = MagicMock(returncode=1, stderr="error", stdout="") - with ( - patch( - "steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=False, - ), - patch( - "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", - script, - ), - patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ), - ): - assert _block_via_hosts_install() is False - - def test_script_fails_no_stderr(self, tmp_path: Path) -> None: - script = tmp_path / "install.sh" - script.touch() - mock_result = MagicMock(returncode=1, stderr="", stdout="out") - with ( - patch( - "steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=False, - ), - patch( - "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", - script, - ), - patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ), - ): - assert _block_via_hosts_install() is False - - def test_script_os_error(self, tmp_path: Path) -> None: - script = tmp_path / "install.sh" - script.touch() - with ( - patch( - "steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=False, - ), - patch( - "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", - script, - ), - patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - side_effect=OSError, - ), - ): - assert _block_via_hosts_install() is False - - -class TestIsIptablesBlocked: - """Tests for _is_iptables_blocked.""" - - def test_blocked(self) -> None: - mock_result = MagicMock(returncode=0, stdout="DROP blah") - with patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ): - assert _is_iptables_blocked() is True - - def test_not_blocked_no_drop(self) -> None: - mock_result = MagicMock(returncode=0, stdout="ACCEPT") - with patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ): - assert _is_iptables_blocked() is False - - def test_not_blocked_error(self) -> None: - mock_result = MagicMock(returncode=1, stdout="") - with patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ): - assert _is_iptables_blocked() is False - - def test_os_error(self) -> None: - with patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - side_effect=OSError, - ): - assert _is_iptables_blocked() is False - - -class TestBlockStoreIptables: - """Tests for _block_store_iptables.""" - - def test_success(self) -> None: - mock_result = MagicMock(returncode=0) - with ( - patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ), - patch( - "steam_backlog_enforcer.store_blocker.socket.getaddrinfo", - return_value=[ - (None, None, None, None, ("1.2.3.4", 443)), - ], - ), - ): - assert _block_store_iptables() is True - - def test_os_error(self) -> None: - with patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - side_effect=OSError, - ): - assert _block_store_iptables() is False - - def test_dns_resolution_fails(self) -> None: - import socket - - mock_result = MagicMock(returncode=0) - with ( - patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ), - patch( - "steam_backlog_enforcer.store_blocker.socket.getaddrinfo", - side_effect=socket.gaierror, - ), - ): - # Should succeed even if DNS fails (just no IPs to block) - assert _block_store_iptables() is True - - def test_chain_hook_needed(self) -> None: - results = [ - MagicMock(returncode=0), # -N - MagicMock(returncode=0), # -F - MagicMock(returncode=1), # -C OUTPUT (not hooked) - MagicMock(returncode=0), # -I OUTPUT - ] - call_count = 0 - - def side_effect(*_args: object, **_kwargs: object) -> MagicMock: - nonlocal call_count - idx = min(call_count, len(results) - 1) - call_count += 1 - return results[idx] - - with ( - patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - side_effect=side_effect, - ), - patch( - "steam_backlog_enforcer.store_blocker.socket.getaddrinfo", - side_effect=__import__("socket").gaierror, - ), - ): - assert _block_store_iptables() is True - - -class TestUnblockStore: - """Tests for unblock_store.""" - - def test_both_succeed(self) -> None: - with ( - patch( - "steam_backlog_enforcer.store_blocker._unblock_store_iptables", - return_value=True, - ), - patch( - "steam_backlog_enforcer.store_blocker._unblock_hosts", - return_value=True, - ), - patch( - "steam_backlog_enforcer.store_blocker.flush_dns_cache", - ), - ): - assert unblock_store() is True - - def test_iptables_fails(self) -> None: - with ( - patch( - "steam_backlog_enforcer.store_blocker._unblock_store_iptables", - return_value=False, - ), - patch( - "steam_backlog_enforcer.store_blocker._unblock_hosts", - return_value=True, - ), - patch( - "steam_backlog_enforcer.store_blocker.flush_dns_cache", - ), - ): - assert unblock_store() is True - - def test_both_fail(self) -> None: - with ( - patch( - "steam_backlog_enforcer.store_blocker._unblock_store_iptables", - return_value=False, - ), - patch( - "steam_backlog_enforcer.store_blocker._unblock_hosts", - return_value=False, - ), - patch( - "steam_backlog_enforcer.store_blocker.flush_dns_cache", - ), - ): - assert unblock_store() is False - - -class TestUnblockStoreIptables: - """Tests for _unblock_store_iptables.""" - - def test_success(self) -> None: - with patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - ): - assert _unblock_store_iptables() is True - - def test_os_error(self) -> None: - with patch( - "steam_backlog_enforcer.store_blocker.subprocess.run", - side_effect=OSError, - ): - assert _unblock_store_iptables() is False diff --git a/steam_backlog_enforcer/tests/test_store_blocker_block_via_hosts_install.py b/steam_backlog_enforcer/tests/test_store_blocker_block_via_hosts_install.py new file mode 100644 index 0000000..764c396 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_store_blocker_block_via_hosts_install.py @@ -0,0 +1,239 @@ +"""Tests for store_blocker module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer.store_blocker import ( + _block_store_iptables, + _block_via_hosts_install, + _is_iptables_blocked, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestBlockViaHostsInstall: + """Tests for _block_via_hosts_install.""" + + def test_already_blocked(self) -> None: + """Test already blocked.""" + with patch( + "steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=True, + ): + assert _block_via_hosts_install() is True + + def test_script_missing(self, tmp_path: Path) -> None: + """Test script missing.""" + with ( + patch( + "steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=False, + ), + patch( + "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", + tmp_path / "nonexistent.sh", + ), + ): + assert _block_via_hosts_install() is False + + def test_script_succeeds(self, tmp_path: Path) -> None: + """Test script succeeds.""" + script = tmp_path / "install.sh" + script.touch() + mock_result = MagicMock(returncode=0) + with ( + patch( + "steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=False, + ), + patch( + "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", + script, + ), + patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ), + ): + assert _block_via_hosts_install() is True + + def test_script_fails(self, tmp_path: Path) -> None: + """Test script fails.""" + script = tmp_path / "install.sh" + script.touch() + mock_result = MagicMock(returncode=1, stderr="error", stdout="") + with ( + patch( + "steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=False, + ), + patch( + "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", + script, + ), + patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ), + ): + assert _block_via_hosts_install() is False + + def test_script_fails_no_stderr(self, tmp_path: Path) -> None: + """Test script fails no stderr.""" + script = tmp_path / "install.sh" + script.touch() + mock_result = MagicMock(returncode=1, stderr="", stdout="out") + with ( + patch( + "steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=False, + ), + patch( + "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", + script, + ), + patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ), + ): + assert _block_via_hosts_install() is False + + def test_script_os_error(self, tmp_path: Path) -> None: + """Test script os error.""" + script = tmp_path / "install.sh" + script.touch() + with ( + patch( + "steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=False, + ), + patch( + "steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", + script, + ), + patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + side_effect=OSError, + ), + ): + assert _block_via_hosts_install() is False + + +class TestIsIptablesBlocked: + """Tests for _is_iptables_blocked.""" + + def test_blocked(self) -> None: + """Test blocked.""" + mock_result = MagicMock(returncode=0, stdout="DROP blah") + with patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ): + assert _is_iptables_blocked() is True + + def test_not_blocked_no_drop(self) -> None: + """Test not blocked no drop.""" + mock_result = MagicMock(returncode=0, stdout="ACCEPT") + with patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ): + assert _is_iptables_blocked() is False + + def test_not_blocked_error(self) -> None: + """Test not blocked error.""" + mock_result = MagicMock(returncode=1, stdout="") + with patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ): + assert _is_iptables_blocked() is False + + def test_os_error(self) -> None: + """Test os error.""" + with patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + side_effect=OSError, + ): + assert _is_iptables_blocked() is False + + +class TestBlockStoreIptables: + """Tests for _block_store_iptables.""" + + def test_success(self) -> None: + """Test success.""" + mock_result = MagicMock(returncode=0) + with ( + patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ), + patch( + "steam_backlog_enforcer.store_blocker.socket.getaddrinfo", + return_value=[ + (None, None, None, None, ("1.2.3.4", 443)), + ], + ), + ): + assert _block_store_iptables() is True + + def test_os_error(self) -> None: + """Test os error.""" + with patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + side_effect=OSError, + ): + assert _block_store_iptables() is False + + def test_dns_resolution_fails(self) -> None: + """Test dns resolution fails.""" + import socket + + mock_result = MagicMock(returncode=0) + with ( + patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ), + patch( + "steam_backlog_enforcer.store_blocker.socket.getaddrinfo", + side_effect=socket.gaierror, + ), + ): + # Should succeed even if DNS fails (just no IPs to block) + assert _block_store_iptables() is True + + def test_chain_hook_needed(self) -> None: + """Test chain hook needed.""" + results = [ + MagicMock(returncode=0), # -N + MagicMock(returncode=0), # -F + MagicMock(returncode=1), # -C OUTPUT (not hooked) + MagicMock(returncode=0), # -I OUTPUT + ] + call_count = 0 + + def side_effect(*_args: object, **_kwargs: object) -> MagicMock: + """Test side effect.""" + nonlocal call_count + idx = min(call_count, len(results) - 1) + call_count += 1 + return results[idx] + + with ( + patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + side_effect=side_effect, + ), + patch( + "steam_backlog_enforcer.store_blocker.socket.getaddrinfo", + side_effect=__import__("socket").gaierror, + ), + ): + assert _block_store_iptables() is True diff --git a/steam_backlog_enforcer/tests/test_store_blocker_unblock_store.py b/steam_backlog_enforcer/tests/test_store_blocker_unblock_store.py new file mode 100644 index 0000000..f31265f --- /dev/null +++ b/steam_backlog_enforcer/tests/test_store_blocker_unblock_store.py @@ -0,0 +1,84 @@ +"""Tests for store_blocker module.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer.store_blocker import ( + _unblock_store_iptables, + unblock_store, +) + + +class TestUnblockStore: + """Tests for unblock_store.""" + + def test_both_succeed(self) -> None: + """Test both succeed.""" + with ( + patch( + "steam_backlog_enforcer.store_blocker._unblock_store_iptables", + return_value=True, + ), + patch( + "steam_backlog_enforcer.store_blocker._unblock_hosts", + return_value=True, + ), + patch( + "steam_backlog_enforcer.store_blocker.flush_dns_cache", + ), + ): + assert unblock_store() is True + + def test_iptables_fails(self) -> None: + """Test iptables fails.""" + with ( + patch( + "steam_backlog_enforcer.store_blocker._unblock_store_iptables", + return_value=False, + ), + patch( + "steam_backlog_enforcer.store_blocker._unblock_hosts", + return_value=True, + ), + patch( + "steam_backlog_enforcer.store_blocker.flush_dns_cache", + ), + ): + assert unblock_store() is True + + def test_both_fail(self) -> None: + """Test both fail.""" + with ( + patch( + "steam_backlog_enforcer.store_blocker._unblock_store_iptables", + return_value=False, + ), + patch( + "steam_backlog_enforcer.store_blocker._unblock_hosts", + return_value=False, + ), + patch( + "steam_backlog_enforcer.store_blocker.flush_dns_cache", + ), + ): + assert unblock_store() is False + + +class TestUnblockStoreIptables: + """Tests for _unblock_store_iptables.""" + + def test_success(self) -> None: + """Test success.""" + with patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + ): + assert _unblock_store_iptables() is True + + def test_os_error(self) -> None: + """Test os error.""" + with patch( + "steam_backlog_enforcer.store_blocker.subprocess.run", + side_effect=OSError, + ): + assert _unblock_store_iptables() is False diff --git a/steam_backlog_enforcer/tests/test_web_dataset.py b/steam_backlog_enforcer/tests/test_web_dataset.py index 68e6cda..40553fe 100644 --- a/steam_backlog_enforcer/tests/test_web_dataset.py +++ b/steam_backlog_enforcer/tests/test_web_dataset.py @@ -3,27 +3,16 @@ from __future__ import annotations from dataclasses import replace -from datetime import datetime, timedelta, timezone -from unittest.mock import patch from steam_backlog_enforcer._web_dataset import ( - HOURS_PER_DAY_PRESETS, - PaceVsHLTB, WebGame, - _build_games, _default_qualifying, _default_summary, _has_any_time, _passes_default_confidence, - _state_info, _sum_positive, _worst_hours, - build_web_dataset, - compute_pace_vs_hltb, - count_complete_since_start, - dataset_to_payload, ) -from steam_backlog_enforcer.config import State from steam_backlog_enforcer.steam_api import GameInfo _PKG = "steam_backlog_enforcer._web_dataset" @@ -64,374 +53,6 @@ def _wg(**over: object) -> WebGame: return replace(base, **over) -class TestWorstHours: - """Tests for _worst_hours (mirrors _stats worst-case selection).""" - - def test_completionist_dominates(self) -> None: - game = _gi(completionist_hours=30.0) - assert _worst_hours(game, cache_hours=10.0, leisure=20.0) == 30.0 - - def test_falls_back_to_cache_and_leisure_when_completionist_zero(self) -> None: - game = _gi(completionist_hours=0.0) - assert _worst_hours(game, cache_hours=15.0, leisure=8.0) == 15.0 - - def test_minus_one_when_all_non_positive(self) -> None: - game = _gi(completionist_hours=0.0) - assert _worst_hours(game, cache_hours=-1.0, leisure=-1.0) == -1.0 - - -class TestPassesDefaultConfidence: - """Tests for _passes_default_confidence.""" - - def test_fail_low_comp_100(self) -> None: - assert _passes_default_confidence(_wg(comp_100_count=2)) is False - - def test_fail_low_count_comp(self) -> None: - assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=10)) is False - - def test_pass_when_all_thresholds_met(self) -> None: - assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=20)) is True - - -class TestHasAnyTime: - """Tests for _has_any_time.""" - - def test_true_when_some_positive(self) -> None: - assert _has_any_time(_wg(rush_hours=-1, leisure_hours=-1, worst_hours=5.0)) - - def test_false_when_all_non_positive(self) -> None: - game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1) - assert _has_any_time(game) is False - - -class TestDefaultQualifying: - """Tests for _default_qualifying — each filter rejection branch.""" - - def test_rejects_low_confidence(self) -> None: - assert _default_qualifying([_wg(count_comp=0)]) == [] - - def test_rejects_unplayable(self) -> None: - game = _wg(protondb_tier="borked", protondb_trending_tier="borked") - assert _default_qualifying([game]) == [] - - def test_rejects_no_time(self) -> None: - game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1) - assert _default_qualifying([game]) == [] - - def test_accepts_qualifying_game(self) -> None: - assert len(_default_qualifying([_wg()])) == 1 - - -class TestSumPositive: - """Tests for _sum_positive.""" - - def test_sums_only_positive(self) -> None: - rows = [_wg(rush_hours=10.0), _wg(rush_hours=-1.0), _wg(rush_hours=5.5)] - assert _sum_positive(rows, "rush_hours") == 15.5 - - def test_empty(self) -> None: - assert _sum_positive([], "rush_hours") == 0.0 - - -class TestDefaultSummary: - """Tests for _default_summary.""" - - def test_totals(self) -> None: - rows = [_wg(rush_hours=10.0, leisure_hours=20.0, worst_hours=25.0)] - summary = _default_summary(rows) - assert summary.qualifying == 1 - assert summary.rush_total == 10.0 - assert summary.leisure_total == 20.0 - assert summary.worst_total == 25.0 - - -class TestCountCompleteSinceStart: - """Tests for count_complete_since_start.""" - - def _ach(self, ts: int, *, achieved: bool = True) -> object: - from steam_backlog_enforcer.steam_api import AchievementInfo - - return AchievementInfo( - api_name="A", display_name="A", achieved=achieved, unlock_time=ts - ) - - def _complete_game(self, app_id: int, unlock_ts: int) -> GameInfo: - achs = [self._ach(unlock_ts)] * 5 - return _gi( - app_id=app_id, - total_achievements=5, - unlocked_achievements=5, - achievements=achs, - ) - - def test_empty_started_at_returns_zero(self) -> None: - games = [self._complete_game(1, 1_000_000)] - assert count_complete_since_start(games, "") == 0 - - def test_invalid_started_at_returns_zero(self) -> None: - games = [self._complete_game(1, 1_000_000)] - assert count_complete_since_start(games, "not-a-date") == 0 - - def test_counts_game_completed_after_start(self) -> None: - started = datetime(2026, 1, 1, tzinfo=timezone.utc) - after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp()) - games = [self._complete_game(1, after_ts)] - assert count_complete_since_start(games, started.isoformat()) == 1 - - def test_excludes_game_completed_before_start(self) -> None: - started = datetime(2026, 1, 1, tzinfo=timezone.utc) - before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp()) - games = [self._complete_game(1, before_ts)] - assert count_complete_since_start(games, started.isoformat()) == 0 - - def test_excludes_incomplete_game(self) -> None: - started = datetime(2026, 1, 1, tzinfo=timezone.utc) - after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp()) - incomplete = _gi( - app_id=1, - total_achievements=5, - unlocked_achievements=3, - achievements=[self._ach(after_ts)] * 3, - ) - assert count_complete_since_start([incomplete], started.isoformat()) == 0 - - def test_excludes_game_with_no_achievement_timestamps(self) -> None: - """Complete game with unlock_time=0 on all achievements is excluded.""" - started = datetime(2026, 1, 1, tzinfo=timezone.utc) - no_ts = _gi( - app_id=1, - total_achievements=5, - unlocked_achievements=5, - achievements=[self._ach(0)] * 5, - ) - assert count_complete_since_start([no_ts], started.isoformat()) == 0 - - def test_mixed_games_counts_only_post_start(self) -> None: - started = datetime(2026, 1, 1, tzinfo=timezone.utc) - after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp()) - before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp()) - games = [ - self._complete_game(1, after_ts), - self._complete_game(2, before_ts), - self._complete_game(3, after_ts), - ] - assert count_complete_since_start(games, started.isoformat()) == 2 - - def test_uses_max_unlock_time_across_achievements(self) -> None: - """Game counts if its LAST achievement was unlocked after start.""" - started = datetime(2026, 1, 1, tzinfo=timezone.utc) - before_ts = int(datetime(2025, 12, 1, tzinfo=timezone.utc).timestamp()) - after_ts = int(datetime(2026, 2, 1, tzinfo=timezone.utc).timestamp()) - # Mix of before/after timestamps — max is after start, so should count - achs = [self._ach(before_ts)] * 4 + [self._ach(after_ts)] - game = _gi( - app_id=1, total_achievements=5, unlocked_achievements=5, achievements=achs - ) - assert count_complete_since_start([game], started.isoformat()) == 1 - - -class TestStateInfo: - """Tests for _state_info pace calculation.""" - - def test_no_start_date(self) -> None: - info = _state_info(State(), games_done=5, games_done_since_start=5) - assert info.days_elapsed == 0 - assert info.pace_games_per_day == 0.0 - assert info.games_done == 5 - assert info.games_done_since_start == 5 - - def test_invalid_start_date(self) -> None: - info = _state_info( - State(enforcement_started_at="not-a-date"), - games_done=5, - games_done_since_start=5, - ) - assert info.days_elapsed == 0 - assert info.pace_games_per_day == 0.0 - - def test_valid_start_with_games(self) -> None: - started = datetime.now(timezone.utc) - timedelta(days=50) - info = _state_info( - State(enforcement_started_at=started.isoformat()), - games_done=12, - games_done_since_start=10, - ) - assert info.days_elapsed >= 49 - assert info.pace_games_per_day > 0.0 - assert info.games_done == 12 - assert info.games_done_since_start == 10 - - def test_valid_start_zero_since_start_keeps_zero_pace(self) -> None: - """games_done_since_start=0 → pace stays 0 even if total games_done > 0.""" - started = datetime.now(timezone.utc) - timedelta(days=50) - info = _state_info( - State(enforcement_started_at=started.isoformat()), - games_done=5, - games_done_since_start=0, - ) - assert info.days_elapsed >= 49 - assert info.pace_games_per_day == 0.0 - - -class TestBuildGames: - """Tests for _build_games (patches cache loaders, no file I/O).""" - - def _run( - self, - games: list[GameInfo], - exclude: set[int], - raw: dict[int, dict[str, object]] | None = None, - protondb: dict[str, dict[str, object]] | None = None, - ) -> list[WebGame]: - with ( - patch(f"{_PKG}._read_raw_cache", return_value=raw or {}), - patch(f"{_PKG}._load_cache", return_value=protondb or {}), - ): - return _build_games(games, exclude) - - def test_skips_complete_games(self) -> None: - rows = self._run( - [_gi(app_id=1, total_achievements=5, unlocked_achievements=5)], set() - ) - assert rows == [] - - def test_skips_excluded_games(self) -> None: - assert self._run([_gi(app_id=1)], {1}) == [] - - def test_uses_cache_entry_when_present(self) -> None: - raw = { - 1: { - "hours": 18.0, - "polls": 7, - "count_comp": 30, - "rush_hours": 9.0, - "leisure_100h": 22.0, - "hltb_game_id": 555, - } - } - proton = {"1": {"tier": "platinum", "trending_tier": "gold", "score": 0.9}} - rows = self._run([_gi(app_id=1, completionist_hours=0.0)], set(), raw, proton) - assert len(rows) == 1 - row = rows[0] - assert row.rush_hours == 9.0 - assert row.leisure_hours == 22.0 - assert row.worst_hours == 22.0 # max(cache 18, leisure 22) - assert row.count_comp == 30 - assert row.comp_100_count == 7 - assert row.hltb_game_id == 555 - assert row.protondb_tier == "platinum" - assert row.protondb_trending_tier == "gold" - - def test_defaults_when_no_cache_entries(self) -> None: - rows = self._run([_gi(app_id=1, completionist_hours=12.0)], set()) - assert len(rows) == 1 - row = rows[0] - assert row.rush_hours == -1 - assert row.leisure_hours == -1 - assert row.worst_hours == 12.0 # completionist only - assert row.protondb_tier == "" # no protondb entry - - -class TestBuildWebDataset: - """Tests for build_web_dataset (top-level projection).""" - - def test_no_snapshot_returns_empty_games(self) -> None: - with ( - patch(f"{_PKG}.load_snapshot", return_value=None), - patch(f"{_PKG}._read_raw_cache", return_value={}), - patch(f"{_PKG}._load_cache", return_value={}), - ): - ds = build_web_dataset(State()) - assert ds.games == [] - assert ds.state.games_done == 0 - assert ds.default_summary.qualifying == 0 - assert ds.defaults.hours_per_day_presets == list(HOURS_PER_DAY_PRESETS) - - def test_excludes_current_app_id(self) -> None: - snapshot = [_gi(app_id=1).to_snapshot(), _gi(app_id=2).to_snapshot()] - raw = { - aid: { - "hours": -1, - "polls": 5, - "count_comp": 20, - "rush_hours": 10.0, - "leisure_100h": 25.0, - "hltb_game_id": 0, - } - for aid in (1, 2) - } - proton = {str(a): {"tier": "gold", "trending_tier": "gold"} for a in (1, 2)} - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch(f"{_PKG}._read_raw_cache", return_value=raw), - patch(f"{_PKG}._load_cache", return_value=proton), - ): - ds = build_web_dataset(State(current_app_id=1)) - assert [g.app_id for g in ds.games] == [2] - - def test_parity_mini_oracle(self) -> None: - """A small hand-checked dataset reproduces qualifying + totals.""" - # g1 qualifies; g2 fails confidence; g3 is complete (excluded). - snapshot = [ - _gi(app_id=1, completionist_hours=0.0).to_snapshot(), - _gi(app_id=2, completionist_hours=0.0).to_snapshot(), - _gi(app_id=3, total_achievements=5, unlocked_achievements=5).to_snapshot(), - ] - raw = { - 1: { - "hours": -1, - "polls": 5, - "count_comp": 20, - "rush_hours": 10.0, - "leisure_100h": 25.0, - "hltb_game_id": 0, - }, - 2: { - "hours": -1, - "polls": 5, - "count_comp": 0, # fails count_comp threshold - "rush_hours": 10.0, - "leisure_100h": 25.0, - "hltb_game_id": 0, - }, - } - proton = {"1": {"tier": "gold", "trending_tier": "gold"}} - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch(f"{_PKG}._read_raw_cache", return_value=raw), - patch(f"{_PKG}._load_cache", return_value=proton), - ): - ds = build_web_dataset(State()) - assert ds.state.games_done == 1 # g3 complete - assert len(ds.games) == 2 # g1 + g2 candidates, g3 excluded - assert ds.default_summary.qualifying == 1 # only g1 - assert ds.default_summary.rush_total == 10.0 - assert ds.default_summary.leisure_total == 25.0 - assert ds.default_summary.worst_total == 25.0 - - -class TestDatasetToPayload: - """Tests for dataset_to_payload.""" - - def test_serializes_to_dict(self) -> None: - with ( - patch(f"{_PKG}.load_snapshot", return_value=None), - patch(f"{_PKG}._read_raw_cache", return_value={}), - patch(f"{_PKG}._load_cache", return_value={}), - ): - payload = dataset_to_payload(build_web_dataset(State())) - assert set(payload) == { - "games", - "state", - "defaults", - "default_summary", - "pace_vs_hltb", - "generated_at", - } - assert isinstance(payload["games"], list) - assert isinstance(payload["state"], dict) - - def _complete_game( app_id: int = 1, playtime_minutes: int = 600, @@ -449,172 +70,97 @@ def _complete_game( ) -class TestComputePaceVsHLTB: - """Tests for compute_pace_vs_hltb — 100 % branch coverage.""" +class TestWorstHours: + """Tests for _worst_hours (mirrors _stats worst-case selection).""" - def test_no_completed_games_returns_none(self) -> None: - incomplete = _gi(app_id=1, total_achievements=10, unlocked_achievements=0) - assert compute_pace_vs_hltb([incomplete], {}) is None + def test_completionist_dominates(self) -> None: + """Test completionist dominates.""" + game = _gi(completionist_hours=30.0) + assert _worst_hours(game, cache_hours=10.0, leisure=20.0) == 30.0 - def test_complete_but_zero_playtime_ignored(self) -> None: - game = _complete_game(playtime_minutes=0) - assert compute_pace_vs_hltb([game], {}) is None + def test_falls_back_to_cache_and_leisure_when_completionist_zero(self) -> None: + """Test falls back to cache and leisure when completionist zero.""" + game = _gi(completionist_hours=0.0) + assert _worst_hours(game, cache_hours=15.0, leisure=8.0) == 15.0 - def test_no_rush_data_in_cache_returns_none(self) -> None: - game = _complete_game(app_id=1) - # cache has hours but no rush_hours - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": -1, - "leisure_100h": -1, - "hltb_game_id": 0, - } - } - assert compute_pace_vs_hltb([game], cache) is None + def test_minus_one_when_all_non_positive(self) -> None: + """Test minus one when all non positive.""" + game = _gi(completionist_hours=0.0) + assert _worst_hours(game, cache_hours=-1.0, leisure=-1.0) == -1.0 - def test_rush_only_ratio_computed(self) -> None: - """With rush but no leisure, ratio_vs_rush is computed, interpolation_t = -1.""" - game = _complete_game(app_id=1, playtime_minutes=600) # 10h actual - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": -1, - "hltb_game_id": 0, - } - } - result = compute_pace_vs_hltb([game], cache) - assert result is not None - assert result.calibration_count == 1 - assert result.ratio_vs_rush == round(10.0 / 8.0, 3) - assert result.ratio_vs_leisure == -1.0 - assert result.interpolation_t == -1.0 - def test_rush_only_style_faster_than_rush_when_ratio_below_one(self) -> None: - """Plays faster than rush (actual < rush) → style = faster_than_rush.""" - game = _complete_game(app_id=1, playtime_minutes=300) # 5h actual - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": -1, - "hltb_game_id": 0, - } - } - result = compute_pace_vs_hltb([game], cache) - assert result is not None - assert result.player_style == "faster_than_rush" +class TestPassesDefaultConfidence: + """Tests for _passes_default_confidence.""" - def test_rush_only_style_unknown_when_ratio_at_or_above_one(self) -> None: - """Without leisure data and ratio >= 1 → style = unknown.""" - game = _complete_game(app_id=1, playtime_minutes=600) # 10h - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": -1, - "hltb_game_id": 0, - } - } - result = compute_pace_vs_hltb([game], cache) - assert result is not None - assert result.player_style == "unknown" + def test_fail_low_comp_100(self) -> None: + """Test fail low comp 100.""" + assert _passes_default_confidence(_wg(comp_100_count=2)) is False - def test_both_rush_and_leisure_interpolation_computed(self) -> None: - """With both rush + leisure, interpolation_t is computed.""" - # actual=10h, rush=8h, leisure=20h → t = (10-8)/(20-8) = 2/12 ≈ 0.167 - game = _complete_game(app_id=1, playtime_minutes=600) - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": 20.0, - "hltb_game_id": 0, - } - } - result = compute_pace_vs_hltb([game], cache) - assert result is not None - assert result.interpolation_t == round((10.0 - 8.0) / (20.0 - 8.0), 3) - assert result.ratio_vs_leisure == round(10.0 / 20.0, 3) - assert result.player_style == "rush_to_leisure" + def test_fail_low_count_comp(self) -> None: + """Test fail low count comp.""" + assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=10)) is False - def test_style_faster_than_rush_when_t_negative(self) -> None: - """t < 0 means faster than rush.""" - game = _complete_game(app_id=1, playtime_minutes=300) # 5h actual - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": 20.0, - "hltb_game_id": 0, - } - } - result = compute_pace_vs_hltb([game], cache) - assert result is not None - assert result.interpolation_t < 0 - assert result.player_style == "faster_than_rush" + def test_pass_when_all_thresholds_met(self) -> None: + """Test pass when all thresholds met.""" + assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=20)) is True - def test_style_slower_than_leisure_when_t_above_one(self) -> None: - """t > 1 means slower than leisure.""" - game = _complete_game(app_id=1, playtime_minutes=1500) # 25h actual - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": 20.0, - "hltb_game_id": 0, - } - } - result = compute_pace_vs_hltb([game], cache) - assert result is not None - assert result.interpolation_t > 1.0 - assert result.player_style == "slower_than_leisure" - def test_interpolation_t_minus_one_when_leisure_not_greater_than_rush(self) -> None: - """Edge case: leisure <= rush, can't divide, interpolation_t = -1.""" - game = _complete_game(app_id=1, playtime_minutes=600) - # leisure == rush → denominator = 0 - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": 8.0, - "hltb_game_id": 0, - } - } - result = compute_pace_vs_hltb([game], cache) - assert result is not None - assert result.interpolation_t == -1.0 +class TestHasAnyTime: + """Tests for _has_any_time.""" - def test_pace_vs_hltb_is_dataclass(self) -> None: - """Return type is PaceVsHLTB.""" - game = _complete_game(app_id=1) - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": 20.0, - "hltb_game_id": 0, - } - } - result = compute_pace_vs_hltb([game], cache) - assert isinstance(result, PaceVsHLTB) + def test_true_when_some_positive(self) -> None: + """Test true when some positive.""" + assert _has_any_time(_wg(rush_hours=-1, leisure_hours=-1, worst_hours=5.0)) + + def test_false_when_all_non_positive(self) -> None: + """Test false when all non positive.""" + game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1) + assert _has_any_time(game) is False + + +class TestDefaultQualifying: + """Tests for _default_qualifying — each filter rejection branch.""" + + def test_rejects_low_confidence(self) -> None: + """Test rejects low confidence.""" + assert _default_qualifying([_wg(count_comp=0)]) == [] + + def test_rejects_unplayable(self) -> None: + """Test rejects unplayable.""" + game = _wg(protondb_tier="borked", protondb_trending_tier="borked") + assert _default_qualifying([game]) == [] + + def test_rejects_no_time(self) -> None: + """Test rejects no time.""" + game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1) + assert _default_qualifying([game]) == [] + + def test_accepts_qualifying_game(self) -> None: + """Test accepts qualifying game.""" + assert len(_default_qualifying([_wg()])) == 1 + + +class TestSumPositive: + """Tests for _sum_positive.""" + + def test_sums_only_positive(self) -> None: + """Test sums only positive.""" + rows = [_wg(rush_hours=10.0), _wg(rush_hours=-1.0), _wg(rush_hours=5.5)] + assert _sum_positive(rows, "rush_hours") == 15.5 + + def test_empty(self) -> None: + """Test empty.""" + assert _sum_positive([], "rush_hours") == 0.0 + + +class TestDefaultSummary: + """Tests for _default_summary.""" + + def test_totals(self) -> None: + """Test totals.""" + rows = [_wg(rush_hours=10.0, leisure_hours=20.0, worst_hours=25.0)] + summary = _default_summary(rows) + assert summary.qualifying == 1 + assert summary.rush_total == 10.0 + assert summary.leisure_total == 20.0 + assert summary.worst_total == 25.0 diff --git a/steam_backlog_enforcer/tests/test_web_dataset_build_games.py b/steam_backlog_enforcer/tests/test_web_dataset_build_games.py new file mode 100644 index 0000000..23af982 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_dataset_build_games.py @@ -0,0 +1,213 @@ +"""Tests for _web_dataset module — 100% branch coverage.""" + +from __future__ import annotations + +from dataclasses import replace +from unittest.mock import patch + +from steam_backlog_enforcer._web_dataset import ( + HOURS_PER_DAY_PRESETS, + WebGame, + _build_games, + build_web_dataset, +) +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._web_dataset" + + +def _gi(**over: object) -> GameInfo: + """Build a GameInfo with field overrides.""" + base = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=60, + completionist_hours=20.0, + comp_100_count=5, + count_comp=20, + ) + return replace(base, **over) + + +def _wg(**over: object) -> WebGame: + """Build a WebGame with field overrides.""" + base = WebGame( + app_id=1, + name="Game1", + completion_pct=0.0, + playtime_minutes=60, + rush_hours=10.0, + leisure_hours=20.0, + worst_hours=25.0, + count_comp=20, + comp_100_count=5, + hltb_game_id=0, + protondb_tier="gold", + protondb_trending_tier="gold", + protondb_score=0.8, + ) + return replace(base, **over) + + +def _complete_game( + app_id: int = 1, + playtime_minutes: int = 600, +) -> GameInfo: + """Complete game (100 % achievements, has playtime).""" + return GameInfo( + app_id=app_id, + name=f"Done{app_id}", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=playtime_minutes, + completionist_hours=0.0, + comp_100_count=5, + count_comp=20, + ) + + +class TestBuildGames: + """Tests for _build_games (patches cache loaders, no file I/O).""" + + def _run( + self, + games: list[GameInfo], + exclude: set[int], + raw: dict[int, dict[str, object]] | None = None, + protondb: dict[str, dict[str, object]] | None = None, + ) -> list[WebGame]: + """Test run.""" + with ( + patch(f"{_PKG}._read_raw_cache", return_value=raw or {}), + patch(f"{_PKG}._load_cache", return_value=protondb or {}), + ): + return _build_games(games, exclude) + + def test_skips_complete_games(self) -> None: + """Test skips complete games.""" + rows = self._run( + [_gi(app_id=1, total_achievements=5, unlocked_achievements=5)], set() + ) + assert rows == [] + + def test_skips_excluded_games(self) -> None: + """Test skips excluded games.""" + assert self._run([_gi(app_id=1)], {1}) == [] + + def test_uses_cache_entry_when_present(self) -> None: + """Test uses cache entry when present.""" + raw = { + 1: { + "hours": 18.0, + "polls": 7, + "count_comp": 30, + "rush_hours": 9.0, + "leisure_100h": 22.0, + "hltb_game_id": 555, + } + } + proton = {"1": {"tier": "platinum", "trending_tier": "gold", "score": 0.9}} + rows = self._run([_gi(app_id=1, completionist_hours=0.0)], set(), raw, proton) + assert len(rows) == 1 + row = rows[0] + assert row.rush_hours == 9.0 + assert row.leisure_hours == 22.0 + assert row.worst_hours == 22.0 # max(cache 18, leisure 22) + assert row.count_comp == 30 + assert row.comp_100_count == 7 + assert row.hltb_game_id == 555 + assert row.protondb_tier == "platinum" + assert row.protondb_trending_tier == "gold" + + def test_defaults_when_no_cache_entries(self) -> None: + """Test defaults when no cache entries.""" + rows = self._run([_gi(app_id=1, completionist_hours=12.0)], set()) + assert len(rows) == 1 + row = rows[0] + assert row.rush_hours == -1 + assert row.leisure_hours == -1 + assert row.worst_hours == 12.0 # completionist only + assert row.protondb_tier == "" # no protondb entry + + +class TestBuildWebDataset: + """Tests for build_web_dataset (top-level projection).""" + + def test_no_snapshot_returns_empty_games(self) -> None: + """Test no snapshot returns empty games.""" + with ( + patch(f"{_PKG}.load_snapshot", return_value=None), + patch(f"{_PKG}._read_raw_cache", return_value={}), + patch(f"{_PKG}._load_cache", return_value={}), + ): + ds = build_web_dataset(State()) + assert ds.games == [] + assert ds.state.games_done == 0 + assert ds.default_summary.qualifying == 0 + assert ds.defaults.hours_per_day_presets == list(HOURS_PER_DAY_PRESETS) + + def test_excludes_current_app_id(self) -> None: + """Test excludes current app id.""" + snapshot = [_gi(app_id=1).to_snapshot(), _gi(app_id=2).to_snapshot()] + raw = { + aid: { + "hours": -1, + "polls": 5, + "count_comp": 20, + "rush_hours": 10.0, + "leisure_100h": 25.0, + "hltb_game_id": 0, + } + for aid in (1, 2) + } + proton = {str(a): {"tier": "gold", "trending_tier": "gold"} for a in (1, 2)} + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch(f"{_PKG}._read_raw_cache", return_value=raw), + patch(f"{_PKG}._load_cache", return_value=proton), + ): + ds = build_web_dataset(State(current_app_id=1)) + assert [g.app_id for g in ds.games] == [2] + + def test_parity_mini_oracle(self) -> None: + """A small hand-checked dataset reproduces qualifying + totals.""" + # g1 qualifies; g2 fails confidence; g3 is complete (excluded). + snapshot = [ + _gi(app_id=1, completionist_hours=0.0).to_snapshot(), + _gi(app_id=2, completionist_hours=0.0).to_snapshot(), + _gi(app_id=3, total_achievements=5, unlocked_achievements=5).to_snapshot(), + ] + raw = { + 1: { + "hours": -1, + "polls": 5, + "count_comp": 20, + "rush_hours": 10.0, + "leisure_100h": 25.0, + "hltb_game_id": 0, + }, + 2: { + "hours": -1, + "polls": 5, + "count_comp": 0, # fails count_comp threshold + "rush_hours": 10.0, + "leisure_100h": 25.0, + "hltb_game_id": 0, + }, + } + proton = {"1": {"tier": "gold", "trending_tier": "gold"}} + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch(f"{_PKG}._read_raw_cache", return_value=raw), + patch(f"{_PKG}._load_cache", return_value=proton), + ): + ds = build_web_dataset(State()) + assert ds.state.games_done == 1 # g3 complete + assert len(ds.games) == 2 # g1 + g2 candidates, g3 excluded + assert ds.default_summary.qualifying == 1 # only g1 + assert ds.default_summary.rush_total == 10.0 + assert ds.default_summary.leisure_total == 25.0 + assert ds.default_summary.worst_total == 25.0 diff --git a/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b.py b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b.py new file mode 100644 index 0000000..88b590c --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b.py @@ -0,0 +1,149 @@ +"""Tests for _web_dataset module — 100% branch coverage.""" + +from __future__ import annotations + +from dataclasses import replace + +from steam_backlog_enforcer._web_dataset import ( + WebGame, + compute_pace_vs_hltb, +) +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._web_dataset" + + +def _gi(**over: object) -> GameInfo: + """Build a GameInfo with field overrides.""" + base = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=60, + completionist_hours=20.0, + comp_100_count=5, + count_comp=20, + ) + return replace(base, **over) + + +def _wg(**over: object) -> WebGame: + """Build a WebGame with field overrides.""" + base = WebGame( + app_id=1, + name="Game1", + completion_pct=0.0, + playtime_minutes=60, + rush_hours=10.0, + leisure_hours=20.0, + worst_hours=25.0, + count_comp=20, + comp_100_count=5, + hltb_game_id=0, + protondb_tier="gold", + protondb_trending_tier="gold", + protondb_score=0.8, + ) + return replace(base, **over) + + +def _complete_game( + app_id: int = 1, + playtime_minutes: int = 600, +) -> GameInfo: + """Complete game (100 % achievements, has playtime).""" + return GameInfo( + app_id=app_id, + name=f"Done{app_id}", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=playtime_minutes, + completionist_hours=0.0, + comp_100_count=5, + count_comp=20, + ) + + +class TestComputePaceVsHLTB: + """Tests for compute_pace_vs_hltb — 100 % branch coverage.""" + + def test_no_completed_games_returns_none(self) -> None: + """Test no completed games returns none.""" + incomplete = _gi(app_id=1, total_achievements=10, unlocked_achievements=0) + assert compute_pace_vs_hltb([incomplete], {}) is None + + def test_complete_but_zero_playtime_ignored(self) -> None: + """Test complete but zero playtime ignored.""" + game = _complete_game(playtime_minutes=0) + assert compute_pace_vs_hltb([game], {}) is None + + def test_no_rush_data_in_cache_returns_none(self) -> None: + """Test no rush data in cache returns none.""" + game = _complete_game(app_id=1) + # cache has hours but no rush_hours + cache = { + 1: { + "hours": 10.0, + "polls": 5, + "count_comp": 20, + "rush_hours": -1, + "leisure_100h": -1, + "hltb_game_id": 0, + } + } + assert compute_pace_vs_hltb([game], cache) is None + + def test_rush_only_ratio_computed(self) -> None: + """With rush but no leisure, ratio_vs_rush is computed, interpolation_t = -1.""" + game = _complete_game(app_id=1, playtime_minutes=600) # 10h actual + cache = { + 1: { + "hours": 10.0, + "polls": 5, + "count_comp": 20, + "rush_hours": 8.0, + "leisure_100h": -1, + "hltb_game_id": 0, + } + } + result = compute_pace_vs_hltb([game], cache) + assert result is not None + assert result.calibration_count == 1 + assert result.ratio_vs_rush == round(10.0 / 8.0, 3) + assert result.ratio_vs_leisure == -1.0 + assert result.interpolation_t == -1.0 + + def test_rush_only_style_faster_than_rush_when_ratio_below_one(self) -> None: + """Plays faster than rush (actual < rush) → style = faster_than_rush.""" + game = _complete_game(app_id=1, playtime_minutes=300) # 5h actual + cache = { + 1: { + "hours": 10.0, + "polls": 5, + "count_comp": 20, + "rush_hours": 8.0, + "leisure_100h": -1, + "hltb_game_id": 0, + } + } + result = compute_pace_vs_hltb([game], cache) + assert result is not None + assert result.player_style == "faster_than_rush" + + def test_rush_only_style_unknown_when_ratio_at_or_above_one(self) -> None: + """Without leisure data and ratio >= 1 → style = unknown.""" + game = _complete_game(app_id=1, playtime_minutes=600) # 10h + cache = { + 1: { + "hours": 10.0, + "polls": 5, + "count_comp": 20, + "rush_hours": 8.0, + "leisure_100h": -1, + "hltb_game_id": 0, + } + } + result = compute_pace_vs_hltb([game], cache) + assert result is not None + assert result.player_style == "unknown" diff --git a/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b_both_rush_and_leisure_interpolation_comp.py b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b_both_rush_and_leisure_interpolation_comp.py new file mode 100644 index 0000000..073592b --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b_both_rush_and_leisure_interpolation_comp.py @@ -0,0 +1,160 @@ +"""Tests for _web_dataset module — 100% branch coverage.""" + +from __future__ import annotations + +from dataclasses import replace + +from steam_backlog_enforcer._web_dataset import ( + PaceVsHLTB, + WebGame, + compute_pace_vs_hltb, +) +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._web_dataset" + + +def _gi(**over: object) -> GameInfo: + """Build a GameInfo with field overrides.""" + base = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=60, + completionist_hours=20.0, + comp_100_count=5, + count_comp=20, + ) + return replace(base, **over) + + +def _wg(**over: object) -> WebGame: + """Build a WebGame with field overrides.""" + base = WebGame( + app_id=1, + name="Game1", + completion_pct=0.0, + playtime_minutes=60, + rush_hours=10.0, + leisure_hours=20.0, + worst_hours=25.0, + count_comp=20, + comp_100_count=5, + hltb_game_id=0, + protondb_tier="gold", + protondb_trending_tier="gold", + protondb_score=0.8, + ) + return replace(base, **over) + + +def _complete_game( + app_id: int = 1, + playtime_minutes: int = 600, +) -> GameInfo: + """Complete game (100 % achievements, has playtime).""" + return GameInfo( + app_id=app_id, + name=f"Done{app_id}", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=playtime_minutes, + completionist_hours=0.0, + comp_100_count=5, + count_comp=20, + ) + + +class TestComputePaceVsHLTBGroup2: + """Tests for compute_pace_vs_hltb — 100 % branch coverage.""" + + def test_both_rush_and_leisure_interpolation_computed(self) -> None: + """With both rush + leisure, interpolation_t is computed.""" + # actual=10h, rush=8h, leisure=20h → t = (10-8)/(20-8) = 2/12 ≈ 0.167 + game = _complete_game(app_id=1, playtime_minutes=600) + cache = { + 1: { + "hours": 10.0, + "polls": 5, + "count_comp": 20, + "rush_hours": 8.0, + "leisure_100h": 20.0, + "hltb_game_id": 0, + } + } + result = compute_pace_vs_hltb([game], cache) + assert result is not None + assert result.interpolation_t == round((10.0 - 8.0) / (20.0 - 8.0), 3) + assert result.ratio_vs_leisure == round(10.0 / 20.0, 3) + assert result.player_style == "rush_to_leisure" + + def test_style_faster_than_rush_when_t_negative(self) -> None: + """t < 0 means faster than rush.""" + game = _complete_game(app_id=1, playtime_minutes=300) # 5h actual + cache = { + 1: { + "hours": 10.0, + "polls": 5, + "count_comp": 20, + "rush_hours": 8.0, + "leisure_100h": 20.0, + "hltb_game_id": 0, + } + } + result = compute_pace_vs_hltb([game], cache) + assert result is not None + assert result.interpolation_t < 0 + assert result.player_style == "faster_than_rush" + + def test_style_slower_than_leisure_when_t_above_one(self) -> None: + """t > 1 means slower than leisure.""" + game = _complete_game(app_id=1, playtime_minutes=1500) # 25h actual + cache = { + 1: { + "hours": 10.0, + "polls": 5, + "count_comp": 20, + "rush_hours": 8.0, + "leisure_100h": 20.0, + "hltb_game_id": 0, + } + } + result = compute_pace_vs_hltb([game], cache) + assert result is not None + assert result.interpolation_t > 1.0 + assert result.player_style == "slower_than_leisure" + + def test_interpolation_t_minus_one_when_leisure_not_greater_than_rush(self) -> None: + """Edge case: leisure <= rush, can't divide, interpolation_t = -1.""" + game = _complete_game(app_id=1, playtime_minutes=600) + # leisure == rush → denominator = 0 + cache = { + 1: { + "hours": 10.0, + "polls": 5, + "count_comp": 20, + "rush_hours": 8.0, + "leisure_100h": 8.0, + "hltb_game_id": 0, + } + } + result = compute_pace_vs_hltb([game], cache) + assert result is not None + assert result.interpolation_t == -1.0 + + def test_pace_vs_hltb_is_dataclass(self) -> None: + """Return type is PaceVsHLTB.""" + game = _complete_game(app_id=1) + cache = { + 1: { + "hours": 10.0, + "polls": 5, + "count_comp": 20, + "rush_hours": 8.0, + "leisure_100h": 20.0, + "hltb_game_id": 0, + } + } + result = compute_pace_vs_hltb([game], cache) + assert isinstance(result, PaceVsHLTB) diff --git a/steam_backlog_enforcer/tests/test_web_dataset_count_complete_since_start.py b/steam_backlog_enforcer/tests/test_web_dataset_count_complete_since_start.py new file mode 100644 index 0000000..4e68c66 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_dataset_count_complete_since_start.py @@ -0,0 +1,207 @@ +"""Tests for _web_dataset module — 100% branch coverage.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timedelta, timezone + +from steam_backlog_enforcer._web_dataset import ( + WebGame, + _state_info, + count_complete_since_start, +) +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._web_dataset" + + +def _gi(**over: object) -> GameInfo: + """Build a GameInfo with field overrides.""" + base = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=60, + completionist_hours=20.0, + comp_100_count=5, + count_comp=20, + ) + return replace(base, **over) + + +def _wg(**over: object) -> WebGame: + """Build a WebGame with field overrides.""" + base = WebGame( + app_id=1, + name="Game1", + completion_pct=0.0, + playtime_minutes=60, + rush_hours=10.0, + leisure_hours=20.0, + worst_hours=25.0, + count_comp=20, + comp_100_count=5, + hltb_game_id=0, + protondb_tier="gold", + protondb_trending_tier="gold", + protondb_score=0.8, + ) + return replace(base, **over) + + +def _complete_game( + app_id: int = 1, + playtime_minutes: int = 600, +) -> GameInfo: + """Complete game (100 % achievements, has playtime).""" + return GameInfo( + app_id=app_id, + name=f"Done{app_id}", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=playtime_minutes, + completionist_hours=0.0, + comp_100_count=5, + count_comp=20, + ) + + +class TestCountCompleteSinceStart: + """Tests for count_complete_since_start.""" + + def _ach(self, ts: int, *, achieved: bool = True) -> object: + """Test ach.""" + from steam_backlog_enforcer.steam_api import AchievementInfo + + return AchievementInfo( + api_name="A", display_name="A", achieved=achieved, unlock_time=ts + ) + + def _complete_game(self, app_id: int, unlock_ts: int) -> GameInfo: + """Test complete game.""" + achs = [self._ach(unlock_ts)] * 5 + return _gi( + app_id=app_id, + total_achievements=5, + unlocked_achievements=5, + achievements=achs, + ) + + def test_empty_started_at_returns_zero(self) -> None: + """Test empty started at returns zero.""" + games = [self._complete_game(1, 1_000_000)] + assert count_complete_since_start(games, "") == 0 + + def test_invalid_started_at_returns_zero(self) -> None: + """Test invalid started at returns zero.""" + games = [self._complete_game(1, 1_000_000)] + assert count_complete_since_start(games, "not-a-date") == 0 + + def test_counts_game_completed_after_start(self) -> None: + """Test counts game completed after start.""" + started = datetime(2026, 1, 1, tzinfo=timezone.utc) + after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp()) + games = [self._complete_game(1, after_ts)] + assert count_complete_since_start(games, started.isoformat()) == 1 + + def test_excludes_game_completed_before_start(self) -> None: + """Test excludes game completed before start.""" + started = datetime(2026, 1, 1, tzinfo=timezone.utc) + before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp()) + games = [self._complete_game(1, before_ts)] + assert count_complete_since_start(games, started.isoformat()) == 0 + + def test_excludes_incomplete_game(self) -> None: + """Test excludes incomplete game.""" + started = datetime(2026, 1, 1, tzinfo=timezone.utc) + after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp()) + incomplete = _gi( + app_id=1, + total_achievements=5, + unlocked_achievements=3, + achievements=[self._ach(after_ts)] * 3, + ) + assert count_complete_since_start([incomplete], started.isoformat()) == 0 + + def test_excludes_game_with_no_achievement_timestamps(self) -> None: + """Complete game with unlock_time=0 on all achievements is excluded.""" + started = datetime(2026, 1, 1, tzinfo=timezone.utc) + no_ts = _gi( + app_id=1, + total_achievements=5, + unlocked_achievements=5, + achievements=[self._ach(0)] * 5, + ) + assert count_complete_since_start([no_ts], started.isoformat()) == 0 + + def test_mixed_games_counts_only_post_start(self) -> None: + """Test mixed games counts only post start.""" + started = datetime(2026, 1, 1, tzinfo=timezone.utc) + after_ts = int(datetime(2026, 6, 1, tzinfo=timezone.utc).timestamp()) + before_ts = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp()) + games = [ + self._complete_game(1, after_ts), + self._complete_game(2, before_ts), + self._complete_game(3, after_ts), + ] + assert count_complete_since_start(games, started.isoformat()) == 2 + + def test_uses_max_unlock_time_across_achievements(self) -> None: + """Game counts if its LAST achievement was unlocked after start.""" + started = datetime(2026, 1, 1, tzinfo=timezone.utc) + before_ts = int(datetime(2025, 12, 1, tzinfo=timezone.utc).timestamp()) + after_ts = int(datetime(2026, 2, 1, tzinfo=timezone.utc).timestamp()) + # Mix of before/after timestamps — max is after start, so should count + achs = [self._ach(before_ts)] * 4 + [self._ach(after_ts)] + game = _gi( + app_id=1, total_achievements=5, unlocked_achievements=5, achievements=achs + ) + assert count_complete_since_start([game], started.isoformat()) == 1 + + +class TestStateInfo: + """Tests for _state_info pace calculation.""" + + def test_no_start_date(self) -> None: + """Test no start date.""" + info = _state_info(State(), games_done=5, games_done_since_start=5) + assert info.days_elapsed == 0 + assert info.pace_games_per_day == 0.0 + assert info.games_done == 5 + assert info.games_done_since_start == 5 + + def test_invalid_start_date(self) -> None: + """Test invalid start date.""" + info = _state_info( + State(enforcement_started_at="not-a-date"), + games_done=5, + games_done_since_start=5, + ) + assert info.days_elapsed == 0 + assert info.pace_games_per_day == 0.0 + + def test_valid_start_with_games(self) -> None: + """Test valid start with games.""" + started = datetime.now(timezone.utc) - timedelta(days=50) + info = _state_info( + State(enforcement_started_at=started.isoformat()), + games_done=12, + games_done_since_start=10, + ) + assert info.days_elapsed >= 49 + assert info.pace_games_per_day > 0.0 + assert info.games_done == 12 + assert info.games_done_since_start == 10 + + def test_valid_start_zero_since_start_keeps_zero_pace(self) -> None: + """games_done_since_start=0 → pace stays 0 even if total games_done > 0.""" + started = datetime.now(timezone.utc) - timedelta(days=50) + info = _state_info( + State(enforcement_started_at=started.isoformat()), + games_done=5, + games_done_since_start=0, + ) + assert info.days_elapsed >= 49 + assert info.pace_games_per_day == 0.0 diff --git a/steam_backlog_enforcer/tests/test_web_dataset_dataset_to_payload.py b/steam_backlog_enforcer/tests/test_web_dataset_dataset_to_payload.py new file mode 100644 index 0000000..45b1412 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_dataset_dataset_to_payload.py @@ -0,0 +1,91 @@ +"""Tests for _web_dataset module — 100% branch coverage.""" + +from __future__ import annotations + +from dataclasses import replace +from unittest.mock import patch + +from steam_backlog_enforcer._web_dataset import ( + WebGame, + build_web_dataset, + dataset_to_payload, +) +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._web_dataset" + + +def _gi(**over: object) -> GameInfo: + """Build a GameInfo with field overrides.""" + base = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=60, + completionist_hours=20.0, + comp_100_count=5, + count_comp=20, + ) + return replace(base, **over) + + +def _wg(**over: object) -> WebGame: + """Build a WebGame with field overrides.""" + base = WebGame( + app_id=1, + name="Game1", + completion_pct=0.0, + playtime_minutes=60, + rush_hours=10.0, + leisure_hours=20.0, + worst_hours=25.0, + count_comp=20, + comp_100_count=5, + hltb_game_id=0, + protondb_tier="gold", + protondb_trending_tier="gold", + protondb_score=0.8, + ) + return replace(base, **over) + + +def _complete_game( + app_id: int = 1, + playtime_minutes: int = 600, +) -> GameInfo: + """Complete game (100 % achievements, has playtime).""" + return GameInfo( + app_id=app_id, + name=f"Done{app_id}", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=playtime_minutes, + completionist_hours=0.0, + comp_100_count=5, + count_comp=20, + ) + + +class TestDatasetToPayload: + """Tests for dataset_to_payload.""" + + def test_serializes_to_dict(self) -> None: + """Test serializes to dict.""" + with ( + patch(f"{_PKG}.load_snapshot", return_value=None), + patch(f"{_PKG}._read_raw_cache", return_value={}), + patch(f"{_PKG}._load_cache", return_value={}), + ): + payload = dataset_to_payload(build_web_dataset(State())) + assert set(payload) == { + "games", + "state", + "defaults", + "default_summary", + "pace_vs_hltb", + "generated_at", + } + assert isinstance(payload["games"], list) + assert isinstance(payload["state"], dict) diff --git a/steam_backlog_enforcer/tests/test_whitelist.py b/steam_backlog_enforcer/tests/test_whitelist.py index f3444e7..8b50c47 100644 --- a/steam_backlog_enforcer/tests/test_whitelist.py +++ b/steam_backlog_enforcer/tests/test_whitelist.py @@ -5,24 +5,13 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import patch -import pytest - from steam_backlog_enforcer._whitelist import ( - _append_audit_log, - _immutable_flag_is, - _load_approved, - _save_approved, _shannon_entropy, _try_set_immutable, - add_pending_exception, - get_approved_exception_ids, - lock_enforcement_files, - unlock_for_write, validate_reason, ) if TYPE_CHECKING: - import array from pathlib import Path # ────────────────────────────────────────────────────────────── @@ -37,78 +26,122 @@ _VALID_REASON = "I need this game installed for a work presentation this week." # ────────────────────────────────────────────────────────────── -class TestShannonEntropy: - def test_empty_string(self) -> None: - assert _shannon_entropy("") == 0.0 - - def test_all_whitespace(self) -> None: - assert _shannon_entropy(" ") == 0.0 - - def test_single_char(self) -> None: - # one unique char → entropy = 0 - assert _shannon_entropy("aaaa") == 0.0 - - def test_high_entropy(self) -> None: - # natural English sentence has decent entropy - assert _shannon_entropy("The quick brown fox jumps") > 3.0 - - # ────────────────────────────────────────────────────────────── # validate_reason # ────────────────────────────────────────────────────────────── -class TestValidateReason: - def test_valid_reason_returns_none(self) -> None: - assert validate_reason(_VALID_REASON) is None - - def test_too_short(self) -> None: - err = validate_reason("short") - assert err is not None - assert "too short" in err - - def test_too_few_words(self) -> None: - # 25+ chars but only 4 words - err = validate_reason("word1 word2 word3 word4xxx") - assert err is not None - assert "words" in err - - def test_low_entropy_rejected(self) -> None: - # repeating 'ab' has low entropy - err = validate_reason("ababababababababababababababab") - assert err is not None - # could be caught by entropy or alternating-pattern check - assert err is not None - - def test_char_run_rejected(self) -> None: - err = validate_reason("I neeeeed this game to play it") - assert err is not None - assert "repeated characters" in err - - def test_alternating_pattern_rejected(self) -> None: - # "ababababab..." repeated many times - err = validate_reason("abababababababababababababababababababababab") - assert err is not None - assert "repetitive" in err or "random" in err or err is not None - - # ────────────────────────────────────────────────────────────── # chattr helpers # ────────────────────────────────────────────────────────────── +# ────────────────────────────────────────────────────────────── +# Persistence helpers (_load_approved, _save_approved) +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# Audit log +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# add_pending_exception +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# get_approved_exception_ids +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# Extra coverage for validate_reason branches 94 & 106 +# ────────────────────────────────────────────────────────────── + + +class TestShannonEntropy: + """Tests for Shannon Entropy.""" + + def test_empty_string(self) -> None: + """Test empty string.""" + assert _shannon_entropy("") == 0.0 + + def test_all_whitespace(self) -> None: + """Test all whitespace.""" + assert _shannon_entropy(" ") == 0.0 + + def test_single_char(self) -> None: + # one unique char → entropy = 0 + """Test single char.""" + assert _shannon_entropy("aaaa") == 0.0 + + def test_high_entropy(self) -> None: + # natural English sentence has decent entropy + """Test high entropy.""" + assert _shannon_entropy("The quick brown fox jumps") > 3.0 + + +class TestValidateReason: + """Tests for Validate Reason.""" + + def test_valid_reason_returns_none(self) -> None: + """Test valid reason returns none.""" + assert validate_reason(_VALID_REASON) is None + + def test_too_short(self) -> None: + """Test too short.""" + err = validate_reason("short") + assert err is not None + assert "too short" in err + + def test_too_few_words(self) -> None: + # 25+ chars but only 4 words + """Test too few words.""" + err = validate_reason("word1 word2 word3 word4xxx") + assert err is not None + assert "words" in err + + def test_low_entropy_rejected(self) -> None: + # repeating 'ab' has low entropy + """Test low entropy rejected.""" + err = validate_reason("ababababababababababababababab") + assert err is not None + # could be caught by entropy or alternating-pattern check + assert err is not None + + def test_char_run_rejected(self) -> None: + """Test char run rejected.""" + err = validate_reason("I neeeeed this game to play it") + assert err is not None + assert "repeated characters" in err + + def test_alternating_pattern_rejected(self) -> None: + # "ababababab..." repeated many times + """Test alternating pattern rejected.""" + err = validate_reason("abababababababababababababababababababababab") + assert err is not None + assert "repetitive" in err or "random" in err or err is not None + + class TestTrySetImmutable: + """Tests for Try Set Immutable.""" + def test_file_does_not_exist(self, tmp_path: Path) -> None: # Should silently do nothing when the file doesn't exist + """Test file does not exist.""" _try_set_immutable(tmp_path / "nonexistent.txt", immutable=True) def test_chattr_not_available(self, tmp_path: Path) -> None: + """Test chattr not available.""" target = tmp_path / "file.txt" target.write_text("data", encoding="utf-8") with patch("shutil.which", return_value=None): _try_set_immutable(target, immutable=True) # no-op, no crash def test_chattr_called_set(self, tmp_path: Path) -> None: + """Test chattr called set.""" target = tmp_path / "file.txt" target.write_text("data", encoding="utf-8") fake_chattr = tmp_path / "chattr" @@ -122,6 +155,7 @@ class TestTrySetImmutable: assert "+i" in args def test_chattr_called_clear(self, tmp_path: Path) -> None: + """Test chattr called clear.""" target = tmp_path / "file.txt" target.write_text("data", encoding="utf-8") fake_chattr = tmp_path / "chattr" @@ -142,6 +176,7 @@ class TestTrySetImmutable: def test_skips_chattr_when_flag_already_correct(self, tmp_path: Path) -> None: # The hot path: lock_enforcement_files runs every loop iteration, and # the flag is already what it should be, so nothing should be spawned. + """Test skips chattr when flag already correct.""" target = tmp_path / "file.txt" target.write_text("data", encoding="utf-8") with ( @@ -155,258 +190,3 @@ class TestTrySetImmutable: _try_set_immutable(target, immutable=True) mock_run.assert_not_called() mock_which.assert_not_called() - - -class TestImmutableFlagIs: - def test_reads_clear_flag_on_real_file(self, tmp_path: Path) -> None: - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - assert _immutable_flag_is(target, immutable=False) is True - assert _immutable_flag_is(target, immutable=True) is False - - def test_fails_closed_when_ioctl_refuses(self, tmp_path: Path) -> None: - # Filesystems without flag support raise OSError; we must report False - # so the caller still shells out rather than silently skipping. - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - with patch("fcntl.ioctl", side_effect=OSError("unsupported")): - assert _immutable_flag_is(target, immutable=False) is False - assert _immutable_flag_is(target, immutable=True) is False - - def test_fails_closed_on_value_error(self, tmp_path: Path) -> None: - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - with patch("fcntl.ioctl", side_effect=ValueError("bad buffer")): - assert _immutable_flag_is(target, immutable=True) is False - - def test_fails_closed_when_file_cannot_be_opened(self, tmp_path: Path) -> None: - assert _immutable_flag_is(tmp_path / "missing.txt", immutable=True) is False - - def test_reports_set_flag(self, tmp_path: Path) -> None: - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - - def _fake_ioctl(_fd: int, _req: int, buf: array.array[int]) -> int: - buf[0] = 0x00000010 # FS_IMMUTABLE_FL - return 0 - - with patch("fcntl.ioctl", side_effect=_fake_ioctl): - assert _immutable_flag_is(target, immutable=True) is True - assert _immutable_flag_is(target, immutable=False) is False - - def test_oserror_swallowed(self, tmp_path: Path) -> None: - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - with ( - patch("shutil.which", return_value="/usr/bin/chattr"), - patch("subprocess.run", side_effect=OSError("no permission")), - ): - _try_set_immutable(target, immutable=True) # must not raise - - def test_timeout_swallowed(self, tmp_path: Path) -> None: - import subprocess - - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - with ( - patch("shutil.which", return_value="/usr/bin/chattr"), - patch( - "subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="chattr", timeout=5), - ), - ): - _try_set_immutable(target, immutable=True) # must not raise - - -class TestLockAndUnlock: - def test_lock_enforcement_files(self, tmp_path: Path) -> None: - cfg = tmp_path / "config.json" - cfg.write_text("{}", encoding="utf-8") - approved = tmp_path / "approved.json" - approved.write_text("[]", encoding="utf-8") - - with ( - patch( - "steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE", - approved, - ), - patch("shutil.which", return_value="/usr/bin/chattr"), - patch("subprocess.run") as mock_run, - ): - lock_enforcement_files(cfg) - assert mock_run.call_count == 2 - all_calls = [c[0][0] for c in mock_run.call_args_list] - assert all("+i" in c for c in all_calls) - - def test_unlock_for_write(self, tmp_path: Path) -> None: - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - with ( - patch("shutil.which", return_value="/usr/bin/chattr"), - # Pretend the file really is immutable; a fresh tmp file is not, - # and unlocking an already-mutable file is now a no-op. - patch( - "steam_backlog_enforcer._whitelist._immutable_flag_is", - return_value=False, - ), - patch("subprocess.run") as mock_run, - ): - unlock_for_write(target) - args = mock_run.call_args[0][0] - assert "-i" in args - - def test_unlock_for_write_skips_already_mutable_file(self, tmp_path: Path) -> None: - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - with patch("subprocess.run") as mock_run: - unlock_for_write(target) - mock_run.assert_not_called() - - -# ────────────────────────────────────────────────────────────── -# Persistence helpers (_load_approved, _save_approved) -# ────────────────────────────────────────────────────────────── - - -class TestPersistence: - def test_load_approved_missing_file(self) -> None: - assert _load_approved() == [] - - def test_load_approved_corrupt_file(self, tmp_path: Path) -> None: - bad = tmp_path / "approved.json" - bad.write_text("{{broken", encoding="utf-8") - with patch( - "steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE", - bad, - ): - assert _load_approved() == [] - - def test_load_approved_non_list(self, tmp_path: Path) -> None: - bad = tmp_path / "approved.json" - bad.write_text('"just a string"', encoding="utf-8") - with patch( - "steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE", - bad, - ): - assert _load_approved() == [] - - def test_save_approved_roundtrip(self) -> None: - entries: list[dict[str, object]] = [ - {"app_id": 730, "reason": "cs2", "approved_at": 99999.0} - ] - with ( - patch("shutil.which", return_value=None), # skip chattr - ): - _save_approved(entries) - assert _load_approved() == entries - - -# ────────────────────────────────────────────────────────────── -# Audit log -# ────────────────────────────────────────────────────────────── - - -class TestAppendAuditLog: - def test_audit_log_written(self, tmp_path: Path) -> None: - log_file = tmp_path / "audit.log" - with patch( - "steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG", - log_file, - ): - _append_audit_log(440, "some reason", "REQUESTED") - content = log_file.read_text(encoding="utf-8") - assert "REQUESTED" in content - assert "app_id=440" in content - assert "some reason" in content - - def test_audit_log_appends(self, tmp_path: Path) -> None: - log_file = tmp_path / "audit.log" - with patch( - "steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG", - log_file, - ): - _append_audit_log(440, "first", "REQUESTED") - _append_audit_log(730, "second", "APPROVED") - content = log_file.read_text(encoding="utf-8") - assert "app_id=440" in content - assert "app_id=730" in content - - -# ────────────────────────────────────────────────────────────── -# add_pending_exception -# ────────────────────────────────────────────────────────────── - - -class TestAddPendingException: - def test_add_new_exception(self) -> None: - with patch("shutil.which", return_value=None): - msg = add_pending_exception(440, _VALID_REASON) - assert "440" in msg - assert "immediately" in msg.lower() - approved = _load_approved() - assert len(approved) == 1 - assert int(approved[0]["app_id"]) == 440 - # Now active right away, no cooldown. - assert 440 in get_approved_exception_ids() - - def test_invalid_reason_raises(self) -> None: - with pytest.raises( - ValueError, match=r"short|words|entropy|repeated|repetitive" - ): - add_pending_exception(440, "too short") - - def test_already_approved_raises(self) -> None: - approved: list[dict[str, object]] = [ - {"app_id": 440, "reason": _VALID_REASON, "approved_at": 0.0} - ] - _save_approved(approved) - with ( - patch("shutil.which", return_value=None), - pytest.raises(ValueError, match="already in the approved"), - ): - add_pending_exception(440, _VALID_REASON) - - -# ────────────────────────────────────────────────────────────── -# get_approved_exception_ids -# ────────────────────────────────────────────────────────────── - - -class TestGetApprovedExceptionIds: - def test_empty(self) -> None: - result = get_approved_exception_ids() - assert result == frozenset() - - def test_populated(self) -> None: - approved: list[dict[str, object]] = [ - {"app_id": 440, "reason": "r", "approved_at": 0.0}, - {"app_id": 730, "reason": "r", "approved_at": 0.0}, - ] - _save_approved(approved) - with patch("shutil.which", return_value=None): - pass - result = get_approved_exception_ids() - assert result == frozenset({440, 730}) - - -# ────────────────────────────────────────────────────────────── -# Extra coverage for validate_reason branches 94 & 106 -# ────────────────────────────────────────────────────────────── - - -class TestValidateReasonExtraBranches: - """Cover lines 94 and 106 that need multi-word, long, low-entropy inputs.""" - - def test_low_entropy_multi_word(self) -> None: - # 8 words, 31+ chars, entropy ≈ 2.0 (< 3.0), no char run, no alt pattern - err = validate_reason("the the the the the the the the") - assert err is not None - assert "entropy" in err - - def test_alternating_pattern_multi_word(self) -> None: - # "abababab" satisfies (..)(\1){3,}, rest provides uniqueness for entropy - reason = "abababab xyz pqr uvw lmn" # 5 words, 24 chars → need 25+ - reason = "abababab xyz pqr uvw lmnop" # 5 words, 26 chars - err = validate_reason(reason) - assert err is not None - assert "repetitive" in err diff --git a/steam_backlog_enforcer/tests/test_whitelist_add_pending_exception.py b/steam_backlog_enforcer/tests/test_whitelist_add_pending_exception.py new file mode 100644 index 0000000..e382dae --- /dev/null +++ b/steam_backlog_enforcer/tests/test_whitelist_add_pending_exception.py @@ -0,0 +1,137 @@ +"""Tests for _whitelist.py: time-locked exceptions, reason validation, chattr.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer._whitelist import ( + _load_approved, + _save_approved, + add_pending_exception, + get_approved_exception_ids, + validate_reason, +) + +# ────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────── + +_VALID_REASON = "I need this game installed for a work presentation this week." + + +# ────────────────────────────────────────────────────────────── +# Shannon entropy +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# validate_reason +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# chattr helpers +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# Persistence helpers (_load_approved, _save_approved) +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# Audit log +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# add_pending_exception +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# get_approved_exception_ids +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# Extra coverage for validate_reason branches 94 & 106 +# ────────────────────────────────────────────────────────────── + + +class TestAddPendingException: + """Tests for Add Pending Exception.""" + + def test_add_new_exception(self) -> None: + """Test add new exception.""" + with patch("shutil.which", return_value=None): + msg = add_pending_exception(440, _VALID_REASON) + assert "440" in msg + assert "immediately" in msg.lower() + approved = _load_approved() + assert len(approved) == 1 + assert int(approved[0]["app_id"]) == 440 + # Now active right away, no cooldown. + assert 440 in get_approved_exception_ids() + + def test_invalid_reason_raises(self) -> None: + """Test invalid reason raises.""" + with pytest.raises( + ValueError, match=r"short|words|entropy|repeated|repetitive" + ): + add_pending_exception(440, "too short") + + def test_already_approved_raises(self) -> None: + """Test already approved raises.""" + approved: list[dict[str, object]] = [ + {"app_id": 440, "reason": _VALID_REASON, "approved_at": 0.0} + ] + _save_approved(approved) + with ( + patch("shutil.which", return_value=None), + pytest.raises(ValueError, match="already in the approved"), + ): + add_pending_exception(440, _VALID_REASON) + + +class TestGetApprovedExceptionIds: + """Tests for Get Approved Exception Ids.""" + + def test_empty(self) -> None: + """Test empty.""" + result = get_approved_exception_ids() + assert result == frozenset() + + def test_populated(self) -> None: + """Test populated.""" + approved: list[dict[str, object]] = [ + {"app_id": 440, "reason": "r", "approved_at": 0.0}, + {"app_id": 730, "reason": "r", "approved_at": 0.0}, + ] + _save_approved(approved) + with patch("shutil.which", return_value=None): + pass + result = get_approved_exception_ids() + assert result == frozenset({440, 730}) + + +class TestValidateReasonExtraBranches: + """Cover lines 94 and 106 that need multi-word, long, low-entropy inputs.""" + + def test_low_entropy_multi_word(self) -> None: + # 8 words, 31+ chars, entropy ≈ 2.0 (< 3.0), no char run, no alt pattern + """Test low entropy multi word.""" + err = validate_reason("the the the the the the the the") + assert err is not None + assert "entropy" in err + + def test_alternating_pattern_multi_word(self) -> None: + # "abababab" satisfies (..)(\1){3,}, rest provides uniqueness for entropy + """Test alternating pattern multi word.""" + reason = "abababab xyz pqr uvw lmn" # 5 words, 24 chars → need 25+ + reason = "abababab xyz pqr uvw lmnop" # 5 words, 26 chars + err = validate_reason(reason) + assert err is not None + assert "repetitive" in err diff --git a/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py b/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py new file mode 100644 index 0000000..2313e96 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py @@ -0,0 +1,225 @@ +"""Tests for _whitelist.py: time-locked exceptions, reason validation, chattr.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +from steam_backlog_enforcer._whitelist import ( + _immutable_flag_is, + _load_approved, + _save_approved, + _try_set_immutable, + lock_enforcement_files, + unlock_for_write, +) + +if TYPE_CHECKING: + import array + from pathlib import Path + +# ────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────── + +_VALID_REASON = "I need this game installed for a work presentation this week." + + +# ────────────────────────────────────────────────────────────── +# Shannon entropy +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# validate_reason +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# chattr helpers +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# Persistence helpers (_load_approved, _save_approved) +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# Audit log +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# add_pending_exception +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# get_approved_exception_ids +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# Extra coverage for validate_reason branches 94 & 106 +# ────────────────────────────────────────────────────────────── + + +class TestImmutableFlagIs: + """Tests for Immutable Flag Is.""" + + def test_reads_clear_flag_on_real_file(self, tmp_path: Path) -> None: + """Test reads clear flag on real file.""" + target = tmp_path / "file.txt" + target.write_text("data", encoding="utf-8") + assert _immutable_flag_is(target, immutable=False) is True + assert _immutable_flag_is(target, immutable=True) is False + + def test_fails_closed_when_ioctl_refuses(self, tmp_path: Path) -> None: + # Filesystems without flag support raise OSError; we must report False + # so the caller still shells out rather than silently skipping. + """Test fails closed when ioctl refuses.""" + target = tmp_path / "file.txt" + target.write_text("data", encoding="utf-8") + with patch("fcntl.ioctl", side_effect=OSError("unsupported")): + assert _immutable_flag_is(target, immutable=False) is False + assert _immutable_flag_is(target, immutable=True) is False + + def test_fails_closed_on_value_error(self, tmp_path: Path) -> None: + """Test fails closed on value error.""" + target = tmp_path / "file.txt" + target.write_text("data", encoding="utf-8") + with patch("fcntl.ioctl", side_effect=ValueError("bad buffer")): + assert _immutable_flag_is(target, immutable=True) is False + + def test_fails_closed_when_file_cannot_be_opened(self, tmp_path: Path) -> None: + """Test fails closed when file cannot be opened.""" + assert _immutable_flag_is(tmp_path / "missing.txt", immutable=True) is False + + def test_reports_set_flag(self, tmp_path: Path) -> None: + """Test reports set flag.""" + target = tmp_path / "file.txt" + target.write_text("data", encoding="utf-8") + + def _fake_ioctl(_fd: int, _req: int, buf: array.array[int]) -> int: + """Test fake ioctl.""" + buf[0] = 0x00000010 # FS_IMMUTABLE_FL + return 0 + + with patch("fcntl.ioctl", side_effect=_fake_ioctl): + assert _immutable_flag_is(target, immutable=True) is True + assert _immutable_flag_is(target, immutable=False) is False + + def test_oserror_swallowed(self, tmp_path: Path) -> None: + """Test oserror swallowed.""" + target = tmp_path / "file.txt" + target.write_text("data", encoding="utf-8") + with ( + patch("shutil.which", return_value="/usr/bin/chattr"), + patch("subprocess.run", side_effect=OSError("no permission")), + ): + _try_set_immutable(target, immutable=True) # must not raise + + def test_timeout_swallowed(self, tmp_path: Path) -> None: + """Test timeout swallowed.""" + import subprocess + + target = tmp_path / "file.txt" + target.write_text("data", encoding="utf-8") + with ( + patch("shutil.which", return_value="/usr/bin/chattr"), + patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="chattr", timeout=5), + ), + ): + _try_set_immutable(target, immutable=True) # must not raise + + +class TestLockAndUnlock: + """Tests for Lock And Unlock.""" + + def test_lock_enforcement_files(self, tmp_path: Path) -> None: + """Test lock enforcement files.""" + cfg = tmp_path / "config.json" + cfg.write_text("{}", encoding="utf-8") + approved = tmp_path / "approved.json" + approved.write_text("[]", encoding="utf-8") + + with ( + patch( + "steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE", + approved, + ), + patch("shutil.which", return_value="/usr/bin/chattr"), + patch("subprocess.run") as mock_run, + ): + lock_enforcement_files(cfg) + assert mock_run.call_count == 2 + all_calls = [c[0][0] for c in mock_run.call_args_list] + assert all("+i" in c for c in all_calls) + + def test_unlock_for_write(self, tmp_path: Path) -> None: + """Test unlock for write.""" + target = tmp_path / "file.txt" + target.write_text("data", encoding="utf-8") + with ( + patch("shutil.which", return_value="/usr/bin/chattr"), + # Pretend the file really is immutable; a fresh tmp file is not, + # and unlocking an already-mutable file is now a no-op. + patch( + "steam_backlog_enforcer._whitelist._immutable_flag_is", + return_value=False, + ), + patch("subprocess.run") as mock_run, + ): + unlock_for_write(target) + args = mock_run.call_args[0][0] + assert "-i" in args + + def test_unlock_for_write_skips_already_mutable_file(self, tmp_path: Path) -> None: + """Test unlock for write skips already mutable file.""" + target = tmp_path / "file.txt" + target.write_text("data", encoding="utf-8") + with patch("subprocess.run") as mock_run: + unlock_for_write(target) + mock_run.assert_not_called() + + +class TestPersistence: + """Tests for Persistence.""" + + def test_load_approved_missing_file(self) -> None: + """Test load approved missing file.""" + assert _load_approved() == [] + + def test_load_approved_corrupt_file(self, tmp_path: Path) -> None: + """Test load approved corrupt file.""" + bad = tmp_path / "approved.json" + bad.write_text("{{broken", encoding="utf-8") + with patch( + "steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE", + bad, + ): + assert _load_approved() == [] + + def test_load_approved_non_list(self, tmp_path: Path) -> None: + """Test load approved non list.""" + bad = tmp_path / "approved.json" + bad.write_text('"just a string"', encoding="utf-8") + with patch( + "steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE", + bad, + ): + assert _load_approved() == [] + + def test_save_approved_roundtrip(self) -> None: + """Test save approved roundtrip.""" + entries: list[dict[str, object]] = [ + {"app_id": 730, "reason": "cs2", "approved_at": 99999.0} + ] + with ( + patch("shutil.which", return_value=None), # skip chattr + ): + _save_approved(entries) + assert _load_approved() == entries diff --git a/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is_append_audit_log.py b/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is_append_audit_log.py new file mode 100644 index 0000000..8c10f7e --- /dev/null +++ b/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is_append_audit_log.py @@ -0,0 +1,89 @@ +"""Tests for _whitelist.py: time-locked exceptions, reason validation, chattr.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +from steam_backlog_enforcer._whitelist import ( + _append_audit_log, +) + +if TYPE_CHECKING: + from pathlib import Path + +# ────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────── + +_VALID_REASON = "I need this game installed for a work presentation this week." + + +# ────────────────────────────────────────────────────────────── +# Shannon entropy +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# validate_reason +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# chattr helpers +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# Persistence helpers (_load_approved, _save_approved) +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# Audit log +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# add_pending_exception +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# get_approved_exception_ids +# ────────────────────────────────────────────────────────────── + + +# ────────────────────────────────────────────────────────────── +# Extra coverage for validate_reason branches 94 & 106 +# ────────────────────────────────────────────────────────────── + + +class TestAppendAuditLog: + """Tests for Append Audit Log.""" + + def test_audit_log_written(self, tmp_path: Path) -> None: + """Test audit log written.""" + log_file = tmp_path / "audit.log" + with patch( + "steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG", + log_file, + ): + _append_audit_log(440, "some reason", "REQUESTED") + content = log_file.read_text(encoding="utf-8") + assert "REQUESTED" in content + assert "app_id=440" in content + assert "some reason" in content + + def test_audit_log_appends(self, tmp_path: Path) -> None: + """Test audit log appends.""" + log_file = tmp_path / "audit.log" + with patch( + "steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG", + log_file, + ): + _append_audit_log(440, "first", "REQUESTED") + _append_audit_log(730, "second", "APPROVED") + content = log_file.read_text(encoding="utf-8") + assert "app_id=440" in content + assert "app_id=730" in content