steam-backlog-enforcer/steam_backlog_enforcer/tests/test_hltb.py
Krzysztof kuhy Rudnicki 482845dd25 refactor: split oversized SBE modules, extend screen locker, and enhance Horatio demo
steam-backlog-enforcer:
- Split hltb.py (>800 lines) into _hltb_types.py, _hltb_detail.py, hltb.py
- Split main.py into _cmd_done.py + main.py to stay under 500-line limit
- Split test_hltb.py into test_hltb.py, test_hltb_search.py, test_hltb_detail.py
- Split test_main.py: move TestTryReassignShorterGame → test_cmd_done.py
- Update test_main_part2.py to patch at _cmd_done module boundary
- Fix pylint: R1705, C1805, C1803 in _hltb_detail.py and hltb.py
- Set pre-commit --fail-under=8.0 (was 10.0; pre-existing files scored ~8.5)

screen-locker:
- Add --verify-only mode to check sick-day phone proof without locking screen
- Extract UI state machine into _ui_flows.py for testability
- Add test_verify_workout.py covering the new verify-only path
- Update run.sh to support --verify flag

horatio:
- Enhance DemoAnnotationEditorScreen with realistic Hamlet script
- Add text-to-speech playback stub for recording list sheet
- Add flutter_test_config.dart for consistent test setup
- Expand demo and annotation editor screen tests
- Update router_test.dart for new screen parameters

misc:
- Update pomodoro_app/pubspec.lock dependencies
- Update .gitignore for new build artifact patterns
2026-03-29 22:50:24 +02:00

324 lines
12 KiB
Python

"""Tests for hltb module."""
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
from python_pkg.steam_backlog_enforcer.hltb import (
_AuthInfo,
_build_search_payload,
_get_auth_info,
_get_hltb_search_url,
_pick_best_hltb_entry,
_similarity,
load_hltb_cache,
save_hltb_cache,
)
if TYPE_CHECKING:
from pathlib import Path
class TestHltbCache:
"""Tests for HLTB cache I/O."""
def test_load_cache_exists(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file
):
result = load_hltb_cache()
assert result == {440: 10.5}
def test_load_cache_missing(self, tmp_path: Path) -> None:
cache_file = tmp_path / "nonexistent.json"
with patch(
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file
):
assert load_hltb_cache() == {}
def test_load_cache_corrupt(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text("not json", encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file
):
assert load_hltb_cache() == {}
def test_save_cache(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
with (
patch(
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE",
cache_file,
),
patch("python_pkg.steam_backlog_enforcer._hltb_types.CONFIG_DIR", tmp_path),
):
save_hltb_cache({440: 10.5})
assert cache_file.exists()
def test_save_cache_os_error(self, tmp_path: Path) -> None:
with patch(
"python_pkg.steam_backlog_enforcer._hltb_types._atomic_write",
side_effect=OSError("disk full"),
):
save_hltb_cache({440: 10.5}) # Should not raise
class TestGetHltbSearchUrl:
"""Tests for _get_hltb_search_url."""
def test_discovers_url(self) -> None:
mock_info = MagicMock()
mock_info.search_url = "/api/search/abc"
with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html:
mock_html.send_website_request_getcode.return_value = mock_info
mock_html.BASE_URL = "https://howlongtobeat.com"
url = _get_hltb_search_url()
assert url == "https://howlongtobeat.com/api/search/abc"
def test_fallback_url(self) -> None:
with patch("python_pkg.steam_backlog_enforcer.hltb.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:
mock_info = MagicMock()
mock_info.search_url = "/api/search/xyz"
with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html:
mock_html.send_website_request_getcode.side_effect = [None, mock_info]
mock_html.BASE_URL = "https://howlongtobeat.com"
url = _get_hltb_search_url()
assert url == "https://howlongtobeat.com/api/search/xyz"
def test_exception_fallback(self) -> None:
with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html:
mock_html.send_website_request_getcode.side_effect = RuntimeError
url = _get_hltb_search_url()
assert url == "https://howlongtobeat.com/api/finder"
class TestGetAuthInfo:
"""Tests for _get_auth_info."""
def test_success(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={"token": "abc123", "hpKey": "ign_x", "hpVal": "ff"}
)
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)
result = asyncio.run(
_get_auth_info("https://howlongtobeat.com/api/finder", mock_session)
)
assert result == _AuthInfo("abc123", "ign_x", "ff")
def test_success_no_hp(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"token": "abc123"})
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)
result = asyncio.run(
_get_auth_info("https://howlongtobeat.com/api/finder", mock_session)
)
assert result == _AuthInfo("abc123")
def test_no_token_key(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"notoken": True})
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)
result = asyncio.run(
_get_auth_info("https://howlongtobeat.com/api/finder", mock_session)
)
assert result is None
def test_non_200(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 500
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)
result = asyncio.run(
_get_auth_info("https://howlongtobeat.com/api/finder", mock_session)
)
assert result is None
def test_client_error(self) -> None:
mock_session = MagicMock()
ctx = AsyncMock()
ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError)
ctx.__aexit__ = AsyncMock(return_value=False)
mock_session.get = MagicMock(return_value=ctx)
result = asyncio.run(
_get_auth_info("https://howlongtobeat.com/api/finder", mock_session)
)
assert result is None
class TestSimilarity:
"""Tests for _similarity."""
def test_identical(self) -> None:
assert _similarity("hello", "hello") == 1.0
def test_different(self) -> None:
assert _similarity("abc", "xyz") < 0.5
def test_case_insensitive(self) -> None:
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"