From 6a9f1378451797170425ccb88b02d50f9184bbf7 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 30 Mar 2026 20:06:46 +0200 Subject: [PATCH] fix: HLTB matching uses game_alias for renamed games & hide library on reassign - _pick_best_hltb_entry: check game_alias in exact-match fallback so renamed games (e.g. 'Needy Streamer Overload' -> 'NEEDY GIRL OVERDOSE') are not beaten by spinoffs with matching prefixes - _try_reassign_shorter_game: call hide_other_games after pick_next_game so library visibility is updated on reassignment, not only on completion - Added tests for alias matching and all hiding branches (100% coverage) --- steam_backlog_enforcer/_cmd_done.py | 8 ++ steam_backlog_enforcer/hltb.py | 1 + steam_backlog_enforcer/tests/test_cmd_done.py | 83 ++++++++++++++++++- steam_backlog_enforcer/tests/test_hltb.py | 24 ++++++ 4 files changed, 115 insertions(+), 1 deletion(-) diff --git a/steam_backlog_enforcer/_cmd_done.py b/steam_backlog_enforcer/_cmd_done.py index eada9b0..a49fd8b 100644 --- a/steam_backlog_enforcer/_cmd_done.py +++ b/steam_backlog_enforcer/_cmd_done.py @@ -108,6 +108,14 @@ def _try_reassign_shorter_game( f" (~{playable.completionist_hours:.1f}h vs ~{hours:.1f}h)" ) pick_next_game(all_games, state, config) + + if state.current_app_id is not None: + owned_ids = get_all_owned_app_ids(config) + if owned_ids: + hidden = hide_other_games(owned_ids, state.current_app_id) + if hidden > 0: + _echo(f"\n Library: hid {hidden} games") + return True diff --git a/steam_backlog_enforcer/hltb.py b/steam_backlog_enforcer/hltb.py index ce05768..e091a09 100644 --- a/steam_backlog_enforcer/hltb.py +++ b/steam_backlog_enforcer/hltb.py @@ -185,6 +185,7 @@ def _pick_best_hltb_entry( reverse=True, ) if (e.get("game_name") or "").lower() == lower + or (e.get("game_alias") or "").lower() == lower ), None, ) diff --git a/steam_backlog_enforcer/tests/test_cmd_done.py b/steam_backlog_enforcer/tests/test_cmd_done.py index 3458fcc..99c6d5c 100644 --- a/steam_backlog_enforcer/tests/test_cmd_done.py +++ b/steam_backlog_enforcer/tests/test_cmd_done.py @@ -56,7 +56,7 @@ class TestTryReassignShorterGame: _snap(1, "Long", 10, 5, 100.0), _snap(2, "Short", 10, 5, 5.0), ] - state = State(current_app_id=1, current_game_name="Long") + state = State(current_app_id=2, current_game_name="Short") short_game = GameInfo( app_id=2, name="Short", @@ -73,6 +73,11 @@ class TestTryReassignShorterGame: return_value=short_game, ), patch(f"{CMD_DONE_PKG}.pick_next_game"), + patch( + f"{CMD_DONE_PKG}.get_all_owned_app_ids", + return_value=[1, 2, 3], + ), + patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=5) as mock_hide, ): result = _try_reassign_shorter_game( {1: 100.0, 2: 5.0}, @@ -82,6 +87,80 @@ class TestTryReassignShorterGame: Config(), ) assert result + mock_hide.assert_called_once_with([1, 2, 3], 2) + + def test_reassigns_no_hide_when_no_owned_ids(self) -> None: + snap = [ + _snap(1, "Long", 10, 5, 100.0), + _snap(2, "Short", 10, 5, 5.0), + ] + state = State(current_app_id=2, current_game_name="Short") + short_game = GameInfo( + app_id=2, + name="Short", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + completionist_hours=5.0, + ) + with ( + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch(f"{CMD_DONE_PKG}._echo") as mock_echo, + patch( + f"{CMD_DONE_PKG}._pick_playable_candidate", + return_value=short_game, + ), + patch(f"{CMD_DONE_PKG}.pick_next_game"), + patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]), + patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=0), + ): + result = _try_reassign_shorter_game( + {1: 100.0, 2: 5.0}, + 1, + 100.0, + state, + Config(), + ) + assert result + # hidden == 0, so "hid N games" should NOT be echoed + for call in mock_echo.call_args_list: + assert "hid" not in str(call) + + def test_reassigns_skip_hide_when_no_app_assigned(self) -> None: + snap = [ + _snap(1, "Long", 10, 5, 100.0), + _snap(2, "Short", 10, 5, 5.0), + ] + state = State(current_app_id=None, current_game_name="") + short_game = GameInfo( + app_id=2, + name="Short", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + completionist_hours=5.0, + ) + with ( + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch(f"{CMD_DONE_PKG}._echo"), + patch( + f"{CMD_DONE_PKG}._pick_playable_candidate", + return_value=short_game, + ), + patch(f"{CMD_DONE_PKG}.pick_next_game"), + patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids") as mock_owned, + patch(f"{CMD_DONE_PKG}.hide_other_games") as mock_hide, + ): + result = _try_reassign_shorter_game( + {1: 100.0, 2: 5.0}, + 1, + 100.0, + state, + Config(), + ) + assert result + mock_owned.assert_not_called() + mock_hide.assert_not_called() def test_playable_none(self) -> None: snap = [ @@ -157,6 +236,8 @@ class TestTryReassignShorterGame: ) as mock_pick_playable, patch(f"{CMD_DONE_PKG}.pick_next_game"), patch(f"{CMD_DONE_PKG}._echo"), + patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]), + patch(f"{CMD_DONE_PKG}.hide_other_games"), ): result = _try_reassign_shorter_game( {1: 20.1}, diff --git a/steam_backlog_enforcer/tests/test_hltb.py b/steam_backlog_enforcer/tests/test_hltb.py index 2cb7be0..7ca1d02 100644 --- a/steam_backlog_enforcer/tests/test_hltb.py +++ b/steam_backlog_enforcer/tests/test_hltb.py @@ -321,3 +321,27 @@ class TestPickBestHltbEntry: 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"